diff options
| -rw-r--r-- | .cargo/config.toml | 2 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | CHANGELOG | 3 | ||||
| -rw-r--r-- | Cargo.lock | 484 | ||||
| -rw-r--r-- | Cargo.toml | 30 | ||||
| -rw-r--r-- | README.md | 116 | ||||
| -rw-r--r-- | goodfile | 9 | ||||
| -rwxr-xr-x | init.sh | 8 | ||||
| -rw-r--r-- | init.sql | 10 | ||||
| -rw-r--r-- | q.sh | 33 | ||||
| -rw-r--r-- | rust-toolchain.toml | 2 | ||||
| -rw-r--r-- | src/main.rs | 360 | ||||
| -rw-r--r-- | src/qroject-bash.rs | 720 | ||||
| -rw-r--r-- | src/shared.rs | 167 |
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") +) @@ -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); @@ -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) + } +} |
