aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoriximeow <me@iximeow.net>2026-03-23 03:16:30 +0000
committeriximeow <me@iximeow.net>2026-04-26 05:19:05 +0000
commit5ef38f263df2cec25d44cb35d1486c2d4d59bc2a (patch)
tree61706a5096440b9f91b0b715eb54ca8afab08979
-rw-r--r--.cargo/config.toml2
-rw-r--r--.gitignore2
-rw-r--r--CHANGELOG3
-rw-r--r--Cargo.lock484
-rw-r--r--Cargo.toml30
-rw-r--r--README.md116
-rw-r--r--goodfile9
-rwxr-xr-xinit.sh8
-rw-r--r--init.sql10
-rw-r--r--q.sh33
-rw-r--r--rust-toolchain.toml2
-rw-r--r--src/main.rs360
-rw-r--r--src/qroject-bash.rs720
-rw-r--r--src/shared.rs167
14 files changed, 1946 insertions, 0 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..7317206
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[unstable]
+build-std = ["core", "alloc"]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b82c298
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+projects.db
diff --git a/CHANGELOG b/CHANGELOG
new file mode 100644
index 0000000..32584a9
--- /dev/null
+++ b/CHANGELOG
@@ -0,0 +1,3 @@
+# 1.0.0
+
+it exists!
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..5af932b
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,484 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "bash-builtins"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89464cf878c1506980b7d84444fac103d30e631d3ce86e4486d50f72430aeb5"
+dependencies = [
+ "bash_builtins_macro",
+ "libc",
+]
+
+[[package]]
+name = "bash_builtins_macro"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52657a5e014b9ecf09c39754247045f525677ffab018a0e5130aa8450f3a227a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "cc"
+version = "1.2.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "js-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "qroject"
+version = "1.0.0"
+dependencies = [
+ "bash-builtins",
+ "clap",
+ "rusqlite",
+ "rustix",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rsqlite-vfs"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
+dependencies = [
+ "hashbrown",
+ "thiserror",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+ "sqlite-wasm-rs",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "sqlite-wasm-rs"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b"
+dependencies = [
+ "cc",
+ "js-sys",
+ "rsqlite-vfs",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..75973d2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,30 @@
+cargo-features = ["panic-immediate-abort", "profile-rustflags"]
+
+[package]
+name = "qroject"
+version = "1.0.0"
+edition = "2024"
+readme = "README.md"
+license = "0BSD"
+
+[[bin]]
+name = "qroject"
+path = "src/main.rs"
+
+[lib]
+name = "qroject_bash"
+path = "src/qroject-bash.rs"
+crate-type = ["cdylib"]
+
+[profile.release]
+panic = "immediate-abort"
+lto = "y"
+# oh my god args_stub's call to libstd std::env::args_os is indirected
+# like a `call [reloc]` instead of `call addr` unless i pass this??
+# rustflags = ["-Zplt=yes"]
+
+[dependencies]
+bash-builtins = "0.4.1"
+clap = { version = "4.6.0", features = ["derive"] }
+rusqlite = "0.39.0"
+rustix = { version = "1.1.4", features = ["system", "process", "fs"] }
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..45e8793
--- /dev/null
+++ b/README.md
@@ -0,0 +1,116 @@
+## `qroject`
+
+a project switcher tool.
+
+this has both `qroject`, the standalone binary, and [Bash loadable
+builtins](https://cgit.git.savannah.gnu.org/cgit/bash.git/tree/examples/loadables/README?h=bash-5.1)
+to mildly-better integrate the tool into normal shell use.
+
+## usage
+
+no nice packaging story yet, so instead there's a bit of get-out-and-push:
+
+* clone this repo, build with a nightly Rust like `cargo +nightly build --release -Z build-std`.
+* put `libqroject_bash.so` somewhere you want to load a shared object from.
+* add the snippets from `q.sh` to your `~/.profile`, `~/.bash_profile`, whichever is appropriate.
+* adjust `enable -f ` to include a path to wherever you've stashed `libqroject_bash.so`.
+
+defaults are such that `libqroject_bash.so` is loaded directly from the
+`qroject` build directory. this is not particularly clean or a great idea.
+
+once that's done, a whirlwind tour:
+
+```
+iximeow@vitharr:~$ qg rust
+iximeow@vitharr:~/code/rust-lang/rust/$ qg yaxpeax-x86
+iximeow@vitharr:~/toy/yaxpeax/arch/x86$ ls $(qd yaxpeax-x86)
+build.rs Cargo.toml data fuzz LICENSE perf.data src test
+Cargo.lock CHANGELOG ffi goodfile Makefile README.md target TODO
+iximeow@vitharr:~/toy/yaxpeax/arch/x86$ cd -
+-bash: cd: OLDPWD not set
+iximeow@vitharr:~/toy/yaxpeax/arch/x86$ qg rust
+iximeow@vitharr:~/code/rust-lang/rust/$ ls $(qd yaxpeax-x86)
+iximeow@vitharr:~/code/rust-lang/rust/$ head -n 1 $(qd yaxpeax-x86)/README.md
+## yaxpeax-x86
+iximeow@vitharr:~/code/rust-lang/rust/$ q list perf
+perf/zen4-zen5-branch-predictor
+iximeow@vitharr:~/code/rust-lang/rust/$ q list lin
+linux
+iximeow@vitharr:~/code/rust-lang/rust/$ q add cgit ~/code/cgit
+iximeow@vitharr:~/code/rust-lang/rust/$ qg cgit
+iximeow@vitharr:~/code/cgit$
+iximeow@vitharr:~/code/cgit$ qg oxide/images
+(root@helios) Password:
+root@helios:/rpool/devel/images#
+```
+
+and from the non-plugin binary:
+
+```
+Usage: qroject [OPTIONS] <COMMAND>
+
+Commands:
+ init initialize a qroject database. you should run this first, and shouldn't need to again
+ add add a project to the qroject database. projects are unique by their name
+ forget forget a project of the given name
+ dir print the directory for the given project
+ info print information about the given project
+ upstream print the project's upstream, if any
+ edit edit `project`'s record
+ list list all known projects (optionally, with details)
+ help Print this message or the help of the given subcommand(s)
+
+Options:
+ --db <DB>
+ -h, --help Print help
+ -V, --version Print version
+```
+
+## related
+
+this is something i've had kicking around in my head for years, but at least a
+few other similar programs exist:
+
+* [z.sh](https://github.com/rupa/z) or [`zsh-z`](https://github.com/agkozak/zsh-z)
+* [zoxide](https://github.com/ajeetdsouza/zoxide)
+* [fzf](https://github.com/junegunn/fzf) kinda sorta
+
+unlike these tools, qroject is oriented specifically towards a curated set of
+working directories and supporting information. this makes it unsuitable for
+finding miscellaneous paths in deep mirrors, for example, but perhaps better for
+figuring out "where did i put those notes from three years ago" (it is to me!)
+
+## todo
+
+project-specific aliases and commands are useful to me, and typically live in
+random gitignored directories like `<proj>/.ixi/tools/foo.sh`. at some point,
+with sufficient motivation, i want to move these to be tracked as part of a
+qroject definition, or at the least load aliases from these files.
+
+this is either good or bad: activating and deactivating aliases will require
+patching the shell's `chdir()` to intercept calls (such as from `q go`!). it
+will involve activating alises possibly from repositories cloned from the
+internet authored by totally untrusted parties. qroject should not allow such
+repos to result in aliases over, say, `git` or `make` to evil ends.
+
+there will probably be public-key cryptography invovled. sorry.
+
+## mirrors
+
+the canonical copy of `qroject` is at
+[https://git.iximeow.net/qroject/](https://git.iximeow.net/qroject/).
+
+`qroject` is also mirrored on Codeberg at
+[https://www.codeberg.org/iximeow/qroject](https://www.codeberg.org/iximeow/qroject).
+
+## shoutouts
+
+huge thanks to internet user [Ayose C.](https://github.com/ayosec) for
+[bash-builtins.rs](https://github.com/ayosec/bash-builtins.rs). this is in fact
+how i discovered that bash loadable plugins *even exist*.
+
+i was initially going to do something moderately less useful as a [multi-call
+binary](https://flameeyes.blog/2009/10/19/multicall-binaries/), and started
+looking at dynamically patching the `qroject`-caller's shell to change the
+caller's `chdir()`. rest assured that `qroject` has not developed any devious
+binary-patching nonsense.
diff --git a/goodfile b/goodfile
new file mode 100644
index 0000000..4c520dc
--- /dev/null
+++ b/goodfile
@@ -0,0 +1,9 @@
+Build.dependencies({"git", "rustc", "cargo"})
+
+Step.start("build")
+Build.run({"cargo", "+nightly", "build", "-Z", "build-std", "--release"})
+
+Build.metric(
+ "libqroject_bash.so (bytes)",
+ Build.environment.size("./target/release/libqroject_bash.so")
+)
diff --git a/init.sh b/init.sh
new file mode 100755
index 0000000..69d4e89
--- /dev/null
+++ b/init.sh
@@ -0,0 +1,8 @@
+#! /usr/bin/env bash
+
+if test -e projects.db; then
+ echo "projects.db exists"
+ exit 1
+fi
+
+sqlite3 projects.db < init.sql
diff --git a/init.sql b/init.sql
new file mode 100644
index 0000000..6381f6d
--- /dev/null
+++ b/init.sql
@@ -0,0 +1,10 @@
+CREATE TABLE projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL,
+ dir BLOB NOT NULL,
+ host TEXT,
+ info TEXT,
+ upstream TEXT
+);
+
+CREATE INDEX projname on projects(name);
diff --git a/q.sh b/q.sh
new file mode 100644
index 0000000..e763657
--- /dev/null
+++ b/q.sh
@@ -0,0 +1,33 @@
+# snippets to wire up qroject into bash nicely.
+
+enable -f ~/qroject/target/release/libqroject_bash.so q qi qg qd
+
+function qcmdcomp() {
+ # for qg, qg, qi, the first and only argument is
+ # the project name.
+ if [ "$1" = "$3" ]; then
+ COMPREPLY=($(q list "$2"))
+ fi
+}
+
+function qcomp() {
+ case "$3" in
+ add)
+ COMPREPLY=($(compgen -A file "$2"))
+ ;;
+ info | upstream | go | dir | edit | list)
+ COMPREPLY=($(q list "$2"))
+ ;;
+ q)
+ COMPREPLY=(add info upstream go dir edit list)
+ ;;
+ *)
+ COMPREPLY=($(compgen -A file "$2"))
+ ;;
+ esac
+}
+
+complete -F qcomp q
+complete -F qcmdcomp qg
+complete -F qcmdcomp qd
+complete -F qcmdcomp qi
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..e1a6ce4
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,2 @@
+[toolchain]
+channel = "nightly-2026-03-13"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..91eba62
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,360 @@
+use std::ffi::OsString;
+use std::io::Read;
+use std::io::Seek;
+use std::io::Write;
+use std::os::unix::prelude::OsStrExt;
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+
+mod shared;
+use shared::{Project, DbCtx};
+
+#[derive(Parser)]
+#[command(version, about, long_about = None)] // , multicall(true))]
+struct Args {
+ #[arg(long)]
+ db: Option<PathBuf>,
+
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// initialize a qroject database. you should run this first, and shouldn't need to again.
+ Init,
+
+ /// add a project to the qroject database. projects are unique by their name.
+ Add {
+ /// the name of the project.
+ project: String,
+ /// the directory at which the project lives.
+ dir: OsString,
+ /// if the project is on some other computer, the hostname or address of that computer.
+ ///
+ /// with this set, `qg <proj>` will ssh to that computer and switch into that directory.
+ #[arg(long)]
+ host: Option<String>,
+ /// misc additional information about the project. not used by qroject itself.
+ info: Option<String>,
+ /// where the project can be found on the wider internet, if anywhere.
+ ///
+ /// this is mostly useful as notes for homepages, git remotes, etc.
+ upstream: Option<String>,
+ },
+
+ /// forget a project of the given name.
+ ///
+ /// this deletes the database row. this does not make changes to any other files. still, be
+ /// careful.
+ Forget {
+ project: String,
+ },
+
+ /// print the directory for the given project.
+ ///
+ /// this is mostly useful as, for example, `$(q dir foo)` as a stand-in for the actual
+ /// directory of foo.
+ Dir {
+ project: String,
+ },
+
+ /// print information about the given project.
+ Info {
+ project: String,
+ },
+
+ /// print the project's upstream, if any.
+ Upstream {
+ project: String,
+ },
+
+ /// edit `project`'s record.
+ ///
+ /// this opens the project's information in vim (not $EDITOR yet sorry). edits to the project
+ /// are written back to the database (purusant to the unique name restriction). optional fields
+ /// are omitted when null, but can be set by adding a corresponding line in the file.
+ Edit {
+ project: String,
+ },
+
+ /// list all known projects (optionally, with details)
+ List {
+ #[arg(short, default_value_t = false)]
+ verbose: bool,
+ },
+}
+
+enum CliResult {
+ Ok,
+ /// a project-specifying command selected a project that doesn't exist
+ NotAProject,
+ /// a project-specifying command wanted a field that was None
+ NoField,
+ /// a command that involved disk i/o failed to either i or o.
+ IoError,
+ /// something happened related to the database
+ DbError,
+}
+
+impl std::process::Termination for CliResult {
+ fn report(self) -> ExitCode {
+ match self {
+ CliResult::Ok => ExitCode::SUCCESS,
+ CliResult::NotAProject => ExitCode::from(1),
+ CliResult::NoField => ExitCode::from(2),
+ CliResult::IoError => ExitCode::from(3),
+ CliResult::DbError => ExitCode::from(4),
+ }
+ }
+}
+
+unsafe extern "C" {
+ // from libc. no rustix binding..
+ fn mkstemp(template: *mut std::ffi::c_char) -> std::ffi::c_int;
+ fn perror(msg: *const std::ffi::c_char);
+}
+
+fn print_project_name(p: Project) {
+ println!("{}", p.name);
+}
+
+fn print_project(p: Project) {
+ println!("{}", p.name);
+ if p.is_here() {
+ println!(" {: <8}: {}", "path", p.dir.display());
+ } else if let Some(host) = p.host.as_ref() {
+ println!(" {: <8}: {}:{}", "path", host, p.dir.display());
+ } else {
+ println!(" {: <8}: {}:{}", "path", "<unknown>", p.dir.display());
+ }
+ if let Some(info) = p.info.as_ref() {
+ println!(" {: <8}: {}", "info", info);
+ }
+ if let Some(upstream) = p.upstream.as_ref() {
+ println!(" {: <8}: {}", "upstream", upstream);
+ }
+}
+
+fn main() -> CliResult {
+ let args = Args::parse();
+
+ let dbpath = args.db.clone().unwrap_or_else(|| {
+ let mut path = std::env::home_dir().expect("have a homedir");
+ path.push(".config");
+ path.push("qroject");
+ path.push("projects.db");
+ path
+ });
+
+ match process(args, dbpath) {
+ Ok(()) => CliResult::Ok,
+ Err(e) => e,
+ }
+}
+
+fn process(args: Args, dbpath: PathBuf) -> Result<(), CliResult> {
+
+ if let Command::Init = args.command {
+ return DbCtx::init(&dbpath).map_err(|e| {
+ eprintln!("{e}");
+ CliResult::IoError
+ });
+ }
+
+ let conn = DbCtx::new(&dbpath).map_err(|e| {
+ eprintln!("{e}");
+ CliResult::IoError
+ })?;
+
+ match args.command {
+ Command::Init => { Ok(()) /* unreachable */ },
+ Command::Add { project, dir, info, upstream, host } => {
+ conn.project_add(
+ Project {
+ id: 0,
+ name: project,
+ dir,
+ info,
+ upstream,
+ host,
+ }
+ ).map_err(|e| {
+ eprintln!("could not add project: {e}");
+ CliResult::DbError
+ })
+ },
+
+ Command::Forget { project } => {
+ conn.project_forget(
+ &project,
+ ).map_err(|e| {
+ eprintln!("could not forget project: {e}");
+ CliResult::DbError
+ })
+ }
+
+ Command::Dir { project } => {
+ let proj = conn.project_by_name(&project).map_err(|e| {
+ eprintln!("could not look up project: {e}");
+ CliResult::DbError
+ })?;
+ let proj = proj.ok_or(CliResult::NotAProject)?;
+
+ println!("{}", proj.dir.display());
+ Ok(())
+ }
+
+ Command::Info { project } => {
+ let proj = conn.project_by_name(&project).map_err(|e| {
+ eprintln!("could not look up project: {e}");
+ CliResult::DbError
+ })?;
+ let proj = proj.ok_or(CliResult::NotAProject)?;
+
+ print_project(proj);
+ Ok(())
+ }
+
+ Command::Upstream { project } => {
+ let proj = conn.project_by_name(&project).map_err(|e| {
+ eprintln!("could not look up project: {e}");
+ CliResult::DbError
+ })?;
+ let proj = proj.ok_or(CliResult::NotAProject)?;
+ let upstream = proj.upstream.as_ref().ok_or(CliResult::NoField)?;
+
+ println!("{}", upstream);
+ Ok(())
+ }
+
+ Command::Edit { project } => {
+ let proj = conn.project_by_name(&project).map_err(|e| {
+ eprintln!("could not look up project: {e}");
+ CliResult::DbError
+ })?;
+ let mut p = proj.ok_or(CliResult::NotAProject)?;
+
+ let mut existing = String::new();
+ use std::fmt::{Write as FmtWrite};
+ use std::os::fd::FromRawFd;
+ writeln!(existing, "name:{}", p.name)
+ .expect("can write to a string...");
+ writeln!(existing, "dir:{}", p.dir.display())
+ .expect("can write to a string...");
+ if let Some(host) = p.host.as_ref() {
+ writeln!(existing, "host:{}", host)
+ .expect("can write to a string...");
+ }
+ if let Some(info) = p.info.as_ref() {
+ writeln!(existing, "info:{}", info)
+ .expect("can write to a string...");
+ }
+ if let Some(upstream) = p.upstream.as_ref() {
+ writeln!(existing, "upstream:{}", upstream)
+ .expect("can write to a string...");
+ }
+
+ let mut template: [u8; 18] = *b"/tmp/qrojectXXXXXX";
+
+ let tmpfd = unsafe { mkstemp(template.as_mut_ptr() as *mut std::ffi::c_char) };
+
+ if tmpfd == -1 {
+ unsafe { perror(b"mkstemp\0".as_ptr() as *const _); }
+ return Err(CliResult::IoError);
+ }
+
+ let mut file = unsafe { std::fs::File::from_raw_fd(tmpfd) };
+
+ let res = file.write_all(existing.as_bytes());
+ if let Err(e) = res {
+ eprintln!("could not write: {:?}", e);
+ return Err(CliResult::IoError);
+ }
+
+ let res = match std::process::Command::new("vim")
+ .arg(str::from_utf8(&template[..]).expect("valid"))
+ .status() {
+ Ok(res) => res,
+ Err(e) => {
+ eprintln!("failed to exec editor: {:?}", e);
+ return Err(CliResult::IoError);
+ }
+ };
+
+ if !res.success() {
+ return Err(CliResult::IoError);
+ }
+
+ let mut edited = String::new();
+ if let Err(_e) = file.rewind() {
+ return Err(CliResult::IoError);
+ }
+ if let Err(_e) = file.read_to_string(&mut edited) {
+ return Err(CliResult::IoError);
+ }
+
+ let lines = edited.split("\n");
+
+ let mut new_name = None;
+ let mut new_dir = None;
+ p.host = None;
+ p.info = None;
+ p.upstream = None;
+
+ for line in lines {
+ if line.trim().is_empty() {
+ continue;
+ }
+ let Some((field, value)) = line.split_once(":") else {
+ eprintln!("bogus line: {line}");
+ return Err(CliResult::IoError);
+ };
+ let value = value.to_owned();
+
+ if field == "name" {
+ new_name = Some(value);
+ } else if field == "dir" {
+ new_dir = Some(value);
+ } else if field == "host" {
+ p.host = Some(value);
+ } else if field == "info" {
+ p.info = Some(value);
+ } else if field == "upstream" {
+ p.upstream = Some(value);
+ }
+ }
+
+ let (Some(name), Some(dir)) = (new_name, new_dir) else {
+ eprintln!("missing name or dir");
+ return Err(CliResult::IoError);
+ };
+
+ p.name = name;
+ let dir: &[u8] = dir.as_bytes();
+ let dir: &std::ffi::OsStr = OsStrExt::from_bytes(dir);
+ p.dir = dir.to_os_string();
+
+ conn.project_update(&p)
+ .map_err(|e| {
+ eprintln!("failed to update project: {}", e);
+ CliResult::IoError
+ })
+ }
+
+ Command::List { verbose: details } => {
+ let op = if details {
+ print_project
+ } else {
+ print_project_name
+ };
+
+ conn.for_each_project(op).map_err(|e| {
+ eprintln!("could iterate projects: {e}");
+ CliResult::DbError
+ })
+ }
+ }
+}
diff --git a/src/qroject-bash.rs b/src/qroject-bash.rs
new file mode 100644
index 0000000..e54b807
--- /dev/null
+++ b/src/qroject-bash.rs
@@ -0,0 +1,720 @@
+use bash_builtins::{
+ builtin_metadata, Args, Builtin, Error, Result
+};
+
+use std::ffi::{CString, OsStr};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::io::{stdout, stderr, Seek, Read, Write};
+use std::path::PathBuf;
+
+#[cfg(target_family="unix")]
+use std::os::unix::ffi::OsStrExt;
+
+mod shared;
+use shared::{Project, DbCtx};
+
+static QROJECT: OnceLock<Arc<Mutex<core::result::Result<QrojectInner, i32>>>> = OnceLock::new();
+
+struct QrojectGo {
+ inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>,
+}
+
+struct QrojectInfo {
+ inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>,
+}
+
+struct QrojectDir {
+ inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>,
+}
+
+struct Qroject {
+ inner: Arc<Mutex<core::result::Result<QrojectInner, i32>>>
+}
+
+impl QrojectGo {
+ fn new() -> Self {
+ Self {
+ inner: QrojectInner::new(),
+ }
+ }
+}
+
+impl Builtin for QrojectGo {
+ fn call(&mut self, args: &mut Args) -> Result<()> {
+ args.no_options()?;
+ let mut arg_iter = args.raw_arguments();
+ let projname = arg_iter.next()
+ .ok_or(Error::ExitCode(1))?
+ .to_str()
+ .map_err(|_| Error::ExitCode(2))?
+ .to_owned();
+ drop(arg_iter);
+ args.finished()?;
+
+ let mut guard = self.inner.lock().unwrap();
+ let handle = guard.as_mut()
+ .map_err(|c| Error::ExitCode(*c))?;
+
+ handle.op_go(&projname)
+ }
+}
+
+impl QrojectInfo {
+ fn new() -> Self {
+ Self {
+ inner: QrojectInner::new(),
+ }
+ }
+}
+
+impl Builtin for QrojectInfo {
+ fn call(&mut self, args: &mut Args) -> Result<()> {
+ args.no_options()?;
+ let mut arg_iter = args.raw_arguments();
+ let projname = arg_iter.next()
+ .ok_or(Error::ExitCode(1))?
+ .to_str()
+ .map_err(|_| Error::ExitCode(2))?
+ .to_owned();
+ drop(arg_iter);
+ args.finished()?;
+
+ let mut guard = self.inner.lock().unwrap();
+ let handle = guard.as_mut()
+ .map_err(|c| Error::ExitCode(*c))?;
+
+ handle.op_info(&projname)
+ }
+}
+
+impl QrojectDir {
+ fn new() -> Self {
+ Self {
+ inner: QrojectInner::new(),
+ }
+ }
+}
+
+impl Builtin for QrojectDir {
+ fn call(&mut self, args: &mut Args) -> Result<()> {
+ args.no_options()?;
+ let mut arg_iter = args.raw_arguments();
+ let projname = arg_iter.next()
+ .ok_or(Error::ExitCode(1))?
+ .to_str()
+ .map_err(|_| Error::ExitCode(2))?
+ .to_owned();
+ drop(arg_iter);
+ args.finished()?;
+
+ let mut guard = self.inner.lock().unwrap();
+ let handle = guard.as_mut()
+ .map_err(|c| Error::ExitCode(*c))?;
+
+ handle.op_dir(&projname)
+ }
+}
+
+impl Qroject {
+ fn new() -> Self {
+ Self {
+ inner: QrojectInner::new(),
+ }
+ }
+}
+
+unsafe extern "C" {
+ // from libc. no rustix binding..
+ fn mkstemp(template: *mut std::ffi::c_char) -> std::ffi::c_int;
+ fn perror(msg: *const std::ffi::c_char);
+
+ // from bash
+ fn evalstring(
+ cmd: *const std::ffi::c_char,
+ from_file: *const std::ffi::c_char,
+ flags: std::ffi::c_int
+ ) -> std::ffi::c_int;
+ fn set_working_directory(path: *const std::ffi::c_char);
+}
+
+fn project_go_chdir(path: &OsStr) -> Result<()> {
+ // do the same mechanics as `cd` as in bash/builtins/cd.def, ish.
+ // that is:
+ // * `chdir()`,
+ // * then set_working_directory,
+ // * then set OLDPWD and PWD.
+
+ let prev_pwd = bash_builtins::variables::find_as_string("PWD");
+
+ rustix::process::chdir(path).map_err(|e| {
+ let _ = writeln!(stderr(), "chdir fail: {e}");
+ Error::ExitCode(5)
+ })?;
+
+ unsafe {
+ set_working_directory(path.as_bytes().as_ptr() as *const std::ffi::c_char);
+ }
+
+ bash_builtins::variables::set("PWD", path.as_bytes())?;
+ if let Some(prev_pwd) = prev_pwd {
+ bash_builtins::variables::set("OLDPWD", prev_pwd.as_bytes())?;
+ }
+
+ Ok(())
+}
+
+fn project_go_ssh(host: &str, p: Project) -> Result<()> {
+ let command = format!(
+ "ssh {} -t 'cd {}; bash'",
+ host,
+ p.dir.display()
+ );
+ let cstr = CString::new(command).expect("TODO: it's valid though!");
+
+ // from `builtins/common.h`
+ const SEVAL_NOHIST: std::ffi::c_int = 0x004;
+ const SEVAL_NOFREE: std::ffi::c_int = 0x008;
+
+ let res = unsafe {
+ evalstring(
+ cstr.as_bytes().as_ptr() as *const _,
+ std::ptr::null(),
+ SEVAL_NOHIST | SEVAL_NOFREE,
+ )
+ };
+ if res != 0 {
+ let _ = writeln!(stderr(), "could not eval");
+ return Err(Error::ExitCode(2));
+ }
+
+ Ok(())
+}
+
+impl Builtin for Qroject {
+ fn call(&mut self, args: &mut Args) -> Result<()> {
+ args.no_options()?;
+ let mut arg_iter = args.raw_arguments();
+ let command = arg_iter.next()
+ .ok_or(Error::ExitCode(1))?
+ .to_str()
+ .map_err(|_| Error::ExitCode(2))?
+ .to_owned();
+
+ if command == "init" {
+ let path = QrojectInner::dbpath();
+ let Some(path) = path else {
+ let _ = writeln!(stderr(), "no home dir?");
+ return Err(Error::ExitCode(2));
+ };
+
+ return match DbCtx::init(path) {
+ Ok(()) => Ok(()),
+ Err(e) => {
+ eprintln!("could not init db: {e}");
+ Err(Error::ExitCode(3))
+ }
+ };
+ }
+
+ let mut guard = self.inner.lock().unwrap();
+ let handle = guard.as_mut()
+ .map_err(|c| Error::ExitCode(*c))?;
+
+ let maybe_projname = arg_iter.next()
+ .map(|arg| match arg.to_str() {
+ Ok(arg) => Ok(arg),
+ Err(e) => {
+ let _ = writeln!(stderr(), "failed to read project name: {e:?}");
+ Err(Error::ExitCode(2))
+ }
+ })
+ .transpose()?;
+
+ match command.as_str() {
+ "info" => {
+ let projname = maybe_projname
+ .ok_or(Error::ExitCode(1))?
+ .to_owned();
+
+ // TODO: check per-command remaining arglists..
+ drop(arg_iter);
+ args.finished()?;
+ handle.op_info(&projname)
+ }
+ "upstream" => {
+ let projname = maybe_projname
+ .ok_or(Error::ExitCode(1))?
+ .to_owned();
+
+ // TODO: check per-command remaining arglists..
+ drop(arg_iter);
+ args.finished()?;
+ handle.op_upstream(&projname)
+ }
+ "go" => {
+ let projname = maybe_projname
+ .ok_or(Error::ExitCode(1))?
+ .to_owned();
+
+ // TODO: check per-command remaining arglists..
+ drop(arg_iter);
+ args.finished()?;
+ handle.op_go(&projname)
+ }
+ "dir" => {
+ let projname = maybe_projname
+ .ok_or(Error::ExitCode(1))?
+ .to_owned();
+
+ // TODO: check per-command remaining arglists..
+ drop(arg_iter);
+ args.finished()?;
+ handle.op_dir(&projname)
+ }
+ "add" => {
+ let projname = maybe_projname
+ .ok_or_else(|| {
+ let _ = writeln!(stderr(), "q add: q add [project] [projdir]");
+ Error::ExitCode(1)
+ })?
+ .to_owned();
+
+ let projdir = arg_iter.next()
+ .ok_or_else(|| {
+ let _ = writeln!(stderr(), "q add: q add [project] [projdir]");
+ Error::ExitCode(1)
+ })?
+ .to_str()
+ .map_err(|_| Error::ExitCode(2))?
+ .to_owned();
+
+ handle.op_add(&projname, &projdir)
+ }
+ "edit" => {
+ let projname = maybe_projname
+ .ok_or(Error::ExitCode(1))?
+ .to_owned();
+
+ handle.op_edit(&projname)
+ }
+ "forget" => {
+ let projname = maybe_projname
+ .ok_or(Error::ExitCode(1))?
+ .to_owned();
+
+ handle.op_forget(&projname)
+ }
+ "list" => {
+ let prefix = if let Some(prefix) = maybe_projname {
+ Some(prefix.to_owned())
+ } else {
+ None
+ };
+
+ // TODO: check per-command remaining arglists..
+ drop(arg_iter);
+ args.finished()?;
+ handle.op_list(prefix.as_ref().map(|s| s.as_str()))
+ }
+ other => {
+ let _ = writeln!(stderr(), "unknown command: {}", other);
+ Err(Error::ExitCode(127))
+ }
+ }
+ }
+}
+
+struct QrojectInner {
+ // can be None if there's no ~/.config/qroject/
+ db: Option<DbCtx>,
+}
+
+impl QrojectInner {
+ fn dbpath() -> Option<PathBuf> {
+ let home = std::env::home_dir();
+ home.map(|mut homedir| {
+ homedir.push(".config");
+ homedir.push("qroject");
+ homedir.push("projects.db");
+ homedir
+ })
+ }
+
+ fn new() -> Arc<Mutex<core::result::Result<Self, i32>>> {
+ let r = QROJECT.get_or_init(|| {
+ let conn = Self::dbpath().as_ref().map(|p| DbCtx::new(p)).transpose();
+ let inner = match conn {
+ Ok(db) => Ok(QrojectInner { db }),
+ Err(e) => {
+ eprintln!("{e}");
+ Err(126)
+ }
+ };
+ Arc::new(Mutex::new(inner))
+ });
+
+ Arc::clone(r)
+ }
+
+ fn op_go(&mut self, projname: &str) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let project = db.project_by_name(&projname)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not find project: {e}");
+ Error::ExitCode(17)
+ })?;
+ let Some(p) = project else {
+ let _ = writeln!(stderr(), "no such project: {}", projname)?;
+ return Err(Error::ExitCode(4));
+ };
+
+ if p.is_here() {
+ project_go_chdir(&p.dir)
+ } else if let Some(host) = p.host.clone().as_ref() {
+ project_go_ssh(host, p)
+ } else {
+ let _ = writeln!(stderr(), "don't know how to get to: {}", projname);
+ return Err(Error::ExitCode(5));
+ }
+ }
+
+ fn op_info(&mut self, projname: &str) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let project = db.project_by_name(&projname)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not find project: {e}");
+ Error::ExitCode(17)
+ })?;
+ let Some(p) = project else {
+ let _ = writeln!(stderr(), "no such project: {}", projname)?;
+ return Err(Error::ExitCode(4));
+ };
+
+ let mut w = stdout();
+ let _ = writeln!(w, "{}", p.name)?;
+ let _ = writeln!(w, " {: <8}: {}", "path", p.dir.display())?;
+ if let Some(info) = p.info.as_ref() {
+ let _ = writeln!(w, " {: <8}: {}", "info", info)?;
+ }
+ if let Some(upstream) = p.upstream.as_ref() {
+ let _ = writeln!(w, " {: <8}: {}", "upstream", upstream)?;
+ }
+
+ Ok(())
+ }
+
+ fn op_upstream(&mut self, projname: &str) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let project = db.project_by_name(&projname)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not find project: {e}");
+ Error::ExitCode(17)
+ })?;
+ let Some(p) = project else {
+ let _ = writeln!(stderr(), "no such project: {}", projname)?;
+ return Err(Error::ExitCode(4));
+ };
+
+ let Some(upstream) = p.upstream.as_ref() else {
+ return Err(Error::ExitCode(1));
+ };
+
+ let mut w = stdout();
+ let _ = writeln!(w, "{}", upstream)?;
+ Ok(())
+ }
+
+ fn op_list(&mut self, prefix: Option<&str>) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let print_project_name = |p: Project| {
+ if let Some(prefix) = prefix {
+ if !p.name.starts_with(prefix) {
+ return;
+ }
+ }
+
+ let _ = writeln!(stdout(), "{}", p.name);
+ };
+
+ db.for_each_project(print_project_name)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not list projects: {e}");
+ Error::ExitCode(2)
+ })
+ }
+
+ fn op_dir(&mut self, projname: &str) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let project = db.project_by_name(&projname)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not find project: {e}");
+ Error::ExitCode(17)
+ })?;
+ let Some(p) = project else {
+ let _ = writeln!(stderr(), "no such project: {}", projname)?;
+ return Err(Error::ExitCode(4));
+ };
+
+ if let Some(host) = p.host.as_ref() {
+ let _ = writeln!(stderr(), "project is at {}, not local", host)?;
+ return Err(Error::ExitCode(5));
+ }
+
+ let mut w = stdout();
+ let _ = writeln!(w, "{}", p.dir.display())?;
+ Ok(())
+ }
+
+ fn op_forget(&mut self, projname: &str) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ db.project_forget(&projname)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not forget {}: {}", projname, e);
+ Error::ExitCode(2)
+ })?;
+
+ Ok(())
+ }
+
+ fn op_edit(&mut self, projname: &str) -> Result<()> {
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let project = db.project_by_name(&projname)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "could not find project: {e}");
+ Error::ExitCode(17)
+ })?;
+ let Some(mut p) = project else {
+ let _ = writeln!(stderr(), "no such project: {}", projname)?;
+ return Err(Error::ExitCode(4));
+ };
+
+ let mut existing = String::new();
+ use std::fmt::{Write as FmtWrite};
+ use std::os::fd::FromRawFd;
+ writeln!(existing, "name:{}", p.name)
+ .map_err(|_| Error::ExitCode(5))?;
+ writeln!(existing, "dir:{}", p.dir.display())
+ .map_err(|_| Error::ExitCode(5))?;
+ if let Some(host) = p.host.as_ref() {
+ writeln!(existing, "host:{}", host)
+ .map_err(|_| Error::ExitCode(5))?;
+ }
+ if let Some(info) = p.info.as_ref() {
+ writeln!(existing, "info:{}", info)
+ .map_err(|_| Error::ExitCode(5))?;
+ }
+ if let Some(upstream) = p.upstream.as_ref() {
+ writeln!(existing, "upstream:{}", upstream)
+ .map_err(|_| Error::ExitCode(5))?;
+ }
+
+ let mut template: [u8; 18] = *b"/tmp/qrojectXXXXXX";
+
+ let tmpfd = unsafe { mkstemp(template.as_mut_ptr() as *mut std::ffi::c_char) };
+
+ if tmpfd == -1 {
+ unsafe { perror(b"mkstemp\0".as_ptr() as *const _); }
+ return Err(Error::ExitCode(5));
+ }
+
+ let mut file = unsafe { std::fs::File::from_raw_fd(tmpfd) };
+
+ let res = file.write_all(existing.as_bytes());
+ if let Err(e) = res {
+ writeln!(stderr(), "could not write: {:?}", e)?;
+ return Err(Error::ExitCode(6));
+ }
+
+ let command = format!(
+ "vim {}",
+ str::from_utf8(&template[..]).expect("valid")
+ );
+ let cstr = CString::new(command).expect("TODO: it's valid though!");
+
+ // from `builtins/common.h`
+ const SEVAL_NOHIST: std::ffi::c_int = 0x004;
+ const SEVAL_NOFREE: std::ffi::c_int = 0x008;
+
+ let res = unsafe {
+ evalstring(
+ cstr.as_bytes().as_ptr() as *const _,
+ std::ptr::null(),
+ SEVAL_NOHIST | SEVAL_NOFREE,
+ )
+ };
+ if res != 0 {
+ let _ = writeln!(stderr(), "could not eval");
+ return Err(Error::ExitCode(2));
+ }
+
+ let mut edited = String::new();
+ file.rewind()
+ .map_err(|_e| Error::ExitCode(5))?;
+ file.read_to_string(&mut edited)
+ .map_err(|_e| Error::ExitCode(5))?;
+
+ let lines = edited.split("\n");
+
+ let mut new_name = None;
+ let mut new_dir = None;
+ p.host = None;
+ p.info = None;
+ p.upstream = None;
+
+ for line in lines {
+ if line.trim().is_empty() {
+ continue;
+ }
+ let Some((field, value)) = line.split_once(":") else {
+ writeln!(stderr(), "bogus line: {line}")?;
+ return Err(Error::ExitCode(7));
+ };
+ let value = value.to_owned();
+
+ if field == "name" {
+ new_name = Some(value);
+ } else if field == "dir" {
+ new_dir = Some(value);
+ } else if field == "host" {
+ p.host = Some(value);
+ } else if field == "info" {
+ p.info = Some(value);
+ } else if field == "upstream" {
+ p.upstream = Some(value);
+ }
+ }
+
+ let (Some(name), Some(dir)) = (new_name, new_dir) else {
+ writeln!(stderr(), "missing name or dir")?;
+ return Err(Error::ExitCode(8));
+ };
+
+ p.name = name;
+ let dir: &[u8] = dir.as_bytes();
+ let dir: &std::ffi::OsStr = OsStrExt::from_bytes(dir);
+ p.dir = dir.to_os_string();
+
+ db.project_update(&p)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "failed to update project: {}", e);
+ Error::ExitCode(9)
+ })?;
+
+ std::fs::remove_file(OsStr::from_bytes(&template))
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "couldn't unlink tempfile? {}", e);
+ Error::ExitCode(10)
+ })?;
+
+ Ok(())
+ }
+
+ fn op_add(&mut self, projname: &str, projdir: &str) -> Result<()> {
+ let abspath = std::path::absolute(projdir)
+ .map_err(|_| Error::ExitCode(8))?;
+
+ let Some(db) = self.db.as_ref() else {
+ return Err(Error::ExitCode(16));
+ };
+
+ let new = Project {
+ id: 0,
+ name: projname.to_string(),
+ dir: abspath.into_os_string(),
+ info: None,
+ upstream: None,
+ host: None,
+ };
+ db.project_add(new)
+ .map_err(|e| {
+ let _ = writeln!(stderr(), "failed to add project: {}", e);
+ Error::ExitCode(9)
+ })?;
+ Ok(())
+ }
+}
+
+builtin_metadata!(
+ name = "q",
+ create = Qroject::new,
+ short_doc = "q {add,go,edit,dir,forget, upstream,info,list} [project]",
+ long_doc = "project switcher.\n\
+ \n\
+ switch to, edit, or print information about, a project. all operations\n\
+ are either shell built-ins of their own (qg, qi, qd), or subcommands\n\
+ of this program, \"q\".\n\
+ \n\
+ COMMANDS:\n\
+ \n \
+ add [projname] [projdir]: add a project named \"projname\" at \n \
+ \"projdir\" to the project database. if this project should \n \
+ have additional fields (like info, upstream, ..) it can be edited\n \
+ after adding.\n \
+ go [projname]: change directory to \"projname\".\n \
+ edit [projname]: edit a project definition in vim (not $EDITOR yet, sorry)\n \
+ dir [projname]: print the directory for \"projname\"\n \
+ upstream [projname]: print the upstream for \"projname\"\n \
+ info [projname]: print all info for \"projname\"\n \
+ forget [projname]: forget about \"projname\". this deletes the row\n \
+ from the database, does not change any other files. still, be careful.\n \
+ list [prefix]: list projects, optionally restricting print all info for \"projname\"\n\
+ ",
+);
+
+builtin_metadata!(
+ name = "qg",
+ create = QrojectGo::new,
+ short_doc = "qg [project]",
+ long_doc = "go to [project]",
+);
+
+builtin_metadata!(
+ name = "qi",
+ create = QrojectInfo::new,
+ short_doc = "qi [project]",
+ long_doc = "info for [project]",
+);
+
+builtin_metadata!(
+ name = "qd",
+ create = QrojectDir::new,
+ short_doc = "qd [project]",
+ long_doc = "print directory for [project], if local",
+);
+
+// this is probably the kind of software that, circa 2026, you'd ask an LLM to cough up.
+// probably would do an ok job of it too, even. see `shoutouts` in the readme for a sense of why i
+// didn't. someone put in the work for `bash_builtins`, complete with rather nice docs! i
+// appreciate that! in the spirit of collaboration, i wanted to build on that.
+//
+// stamping out a few thousand lines of emdashful code would not have known about "related
+// bindings". try it if you want. there's no love of the game in it. fully spiritless. it's very
+// easy to simply Not Care about our peers and neighbors in open source. don't do that. it's
+// important to exist, and we only exist if we see each other.
+//
+// i had fun learning how to put this together and hope you have a bit of fun reading this too. or
+// maybe it's just useful, and you never read this; that's ok too.
+//
+// it's wartful, because of course it is, because *i* coughed this up in basically a day.
+// hope you appreciate those too.
diff --git a/src/shared.rs b/src/shared.rs
new file mode 100644
index 0000000..bbca537
--- /dev/null
+++ b/src/shared.rs
@@ -0,0 +1,167 @@
+use std::ffi::{OsStr, OsString};
+#[cfg(target_family="unix")]
+use std::os::unix::ffi::OsStrExt;
+#[cfg(target_family="windows")]
+use std::os::windows::ffi::OsStrExt;
+use std::sync::Mutex;
+use std::path::Path;
+
+use rusqlite::{Connection, params};
+
+// in some configurations (`main.rs`), nothing actually uses id.
+#[allow(dead_code)]
+pub struct Project {
+ pub id: u32,
+ pub name: String,
+ pub dir: OsString,
+ pub host: Option<String>,
+ pub info: Option<String>,
+ pub upstream: Option<String>,
+}
+
+impl Project {
+ pub fn is_here(&self) -> bool {
+ let uname = rustix::system::uname();
+ let me = uname.nodename();
+ if let Some(host) = self.host.as_ref() {
+ host.as_bytes() == me.to_bytes()
+ } else {
+ std::path::Path::new(&self.dir).exists()
+ }
+ }
+}
+
+pub struct DbCtx {
+ pub conn: Mutex<Connection>,
+}
+
+impl DbCtx {
+ pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self, String> {
+ let conn = Connection::open(db_path)
+ .map_err(|e| format!("failed to open db: {e}"))?;
+
+ Ok(Self {
+ conn: Mutex::new(conn)
+ })
+ }
+
+ pub fn init<P: AsRef<Path>>(db_path: P) -> Result<(), String> {
+ let conn = Connection::open(db_path)
+ .map_err(|e| format!("failed to open db: {e}"))?;
+
+ conn.execute(
+ "CREATE TABLE projects (\
+ id INTEGER PRIMARY KEY AUTOINCREMENT, \
+ name TEXT UNIQUE NOT NULL, \
+ dir BLOB NOT NULL, \
+ host TEXT, \
+ info TEXT, \
+ upstream TEXT\
+ );", params![])
+ .map_err(|e| format!("failed to create projects table: {e}"))?;
+
+ conn.execute(
+ "CREATE INDEX projname on projects(name);", params![])
+ .map_err(|e| format!("failed to create index?!: {e}"))?;
+
+ Ok(())
+ }
+
+ pub fn project_add(&self, p: Project) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ conn
+ .execute(
+ "insert into projects \
+ (name, dir, host, info, upstream) \
+ values (?1, ?2, ?3, ?4, ?5);",/* \
+ on conflict (name) do update \
+ set dir=excluded.dir \
+ info=excluded.info \
+ upstream=excluded.upstream;",*/
+ params![p.name, p.dir.as_os_str().as_bytes(), p.host, p.info, p.upstream]
+ )
+ .map_err(|e| format!("failed to insert project: {e}"))?;
+ Ok(())
+ }
+
+ pub fn project_update(&self, p: &Project) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ // TODO: don't panic on name conflict..
+ conn
+ .execute(
+ "update projects \
+ set name=?2, \
+ dir=?3, \
+ host=?4, \
+ info=?5, \
+ upstream=?6 \
+ where id=?1",
+ params![p.id.to_string(), p.name, p.dir.as_os_str().as_bytes(), p.host, p.info, p.upstream]
+ )
+ .map_err(|e| format!("failed to update project: {e}"))?;
+ Ok(())
+ }
+
+ pub fn project_forget(&self, name: &str) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ conn
+ .execute(
+ "delete from projects where name=?1;",
+ params![name]
+ )
+ .map_err(|e| format!("failed to delete project: {e}"))?;
+ Ok(())
+ }
+
+ pub fn for_each_project(&self, op: impl Fn(Project)) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ let mut stmt = conn.prepare("select * from projects").expect("can prepare");
+ let mut rows = stmt.query([])
+ .map_err(|e| format!("failed to select projects: {e}"))?;
+
+ while let Some(row) = rows.next().expect("can read row") {
+ let id: u32 = row.get(0).expect("typecheck");
+ let dir: Box<[u8]> = row.get(2).expect("typecheck");
+ let osstr: &OsStr = OsStrExt::from_bytes(&dir);
+ let project = Project {
+ id,
+ name: row.get(1).expect("typecheck"),
+ dir: osstr.to_os_string(),
+ host: row.get(3).expect("typecheck"),
+ info: row.get(4).expect("typecheck"),
+ upstream: row.get(5).expect("typecheck"),
+ };
+
+ op(project);
+ }
+ Ok(())
+ }
+
+ pub fn project_by_name(&self, name: &str) -> Result<Option<Project>, String> {
+ let conn = self.conn.lock().unwrap();
+ let mut stmt = conn.prepare("select id, name, dir, host, info, upstream from projects where name=?1")
+ .expect("can prepare");
+ let mut rows = stmt.query([name])
+ .map_err(|e| format!("failed to find projects: {e}"))?;
+
+ let project = if let Some(row) = rows.next().expect("can read row") {
+ let id: u32 = row.get(0).expect("typecheck");
+ let dir: Box<[u8]> = row.get(2).expect("typecheck");
+ let osstr: &OsStr = OsStrExt::from_bytes(&dir);
+ Some(Project {
+ id,
+ name: row.get(1).expect("typecheck"),
+ dir: osstr.to_os_string(),
+ host: row.get(3).expect("typecheck"),
+ info: row.get(4).expect("typecheck"),
+ upstream: row.get(5).expect("typecheck"),
+ })
+ } else {
+ None
+ };
+
+ assert!(rows.next().expect("can try getting next row").is_none());
+
+ Ok(project)
+ }
+}