From 5ef38f263df2cec25d44cb35d1486c2d4d59bc2a Mon Sep 17 00:00:00 2001 From: iximeow Date: Mon, 23 Mar 2026 03:16:30 +0000 Subject: initial --- .cargo/config.toml | 2 + .gitignore | 2 + CHANGELOG | 3 + Cargo.lock | 484 +++++++++++++++++++++++++++++++++++ Cargo.toml | 30 +++ README.md | 116 +++++++++ goodfile | 9 + init.sh | 8 + init.sql | 10 + q.sh | 33 +++ rust-toolchain.toml | 2 + src/main.rs | 360 ++++++++++++++++++++++++++ src/qroject-bash.rs | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/shared.rs | 167 ++++++++++++ 14 files changed, 1946 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 CHANGELOG create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 goodfile create mode 100755 init.sh create mode 100644 init.sql create mode 100644 q.sh create mode 100644 rust-toolchain.toml create mode 100644 src/main.rs create mode 100644 src/qroject-bash.rs create mode 100644 src/shared.rs 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] + +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 + -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 `/.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, + + #[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 ` will ssh to that computer and switch into that directory. + #[arg(long)] + host: Option, + /// misc additional information about the project. not used by qroject itself. + info: Option, + /// 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, + }, + + /// 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", "", 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>>> = OnceLock::new(); + +struct QrojectGo { + inner: Arc>>, +} + +struct QrojectInfo { + inner: Arc>>, +} + +struct QrojectDir { + inner: Arc>>, +} + +struct Qroject { + inner: Arc>> +} + +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, +} + +impl QrojectInner { + fn dbpath() -> Option { + let home = std::env::home_dir(); + home.map(|mut homedir| { + homedir.push(".config"); + homedir.push("qroject"); + homedir.push("projects.db"); + homedir + }) + } + + fn new() -> Arc>> { + 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, + pub info: Option, + pub upstream: Option, +} + +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, +} + +impl DbCtx { + pub fn new>(db_path: P) -> Result { + let conn = Connection::open(db_path) + .map_err(|e| format!("failed to open db: {e}"))?; + + Ok(Self { + conn: Mutex::new(conn) + }) + } + + pub fn init>(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, 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) + } +} -- cgit v1.1