diff --git a/.gitignore b/.gitignore index 73fab07..c4a91a9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,35 @@ target/ # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + +# the cargo home CI keeps inside the project so it lands in the cache +.cargo/ + +# where the completions build writes when it is not given a directory +completions/ + +# os +.DS_Store + +# editors +.vscode +.idea +.ignore + +# ai +CLAUDE.local.md +.claude + +# languages +.python-version + +# nix +.nix-venv +.envrc +.direnv/ + +# project-local +TODO.md +# database dumps, which this tool exists to keep out of a repository +dumps +*.partial diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9e549e6..37d1bf6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,30 +5,38 @@ default: files: - Cargo.lock paths: + # no .cargo/bin here: the audit job runs what is in it, and this cache is + # shared by every branch and merge request, so a pipeline could otherwise + # leave behind the binary a later scheduled audit executes - .cargo/registry - # so the audit job does not rebuild cargo-audit on every schedule - - .cargo/bin - target variables: # keep the registry inside the project so it lands in the cache CARGO_HOME: $CI_PROJECT_DIR/.cargo CARGO_TERM_COLOR: always + # pinned, rather than whatever version exists on the night this runs + CARGO_AUDIT_VERSION: "0.21.2" # without this a push to a branch with an open merge request runs twice workflow: rules: + # first, because a scheduled pipeline sets CI_COMMIT_BRANCH as well and so + # would be matched by the branch rule below, or vetoed by the one above it + - if: $CI_PIPELINE_SOURCE == "schedule" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never - if: $CI_COMMIT_BRANCH - - if: $CI_PIPELINE_SOURCE == "schedule" check: script: - rustup component add rustfmt clippy - cargo fmt --check - - cargo clippy --all-targets -- -D warnings + # --locked on the first cargo invocation too: without it clippy resolves the + # dependencies and rewrites a Cargo.lock that has drifted from Cargo.toml, + # and the --locked commands after it then pass against what it just wrote + - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked - cargo build --release --locked rules: @@ -39,19 +47,33 @@ check: # runs only from a pipeline schedule, so set one up in the project settings audit: script: - - cargo install cargo-audit --locked + - cargo install cargo-audit --version $CARGO_AUDIT_VERSION --locked - cargo audit # reports dependencies that have drifted behind, without changing the lockfile - cargo update --dry-run + # its own cache, so the audit tool is not rebuilt on every schedule while + # still being written only by this schedule-only job. the pinned version is + # part of the key, so a bump fetches rather than reusing the old binary + cache: + key: audit-tools-$CARGO_AUDIT_VERSION + paths: + - .cargo/bin + - .cargo/registry rules: - if: $CI_PIPELINE_SOURCE == "schedule" - allow_failure: true # the msrv declared in Cargo.toml, so it fails when something needs a newer rustc msrv: image: rust:1.85 script: - cargo build --locked + # its own key: artifacts built by another rustc are of no use to this job, and + # sharing one only has the two toolchains taking turns overwriting it + cache: + key: msrv-$CI_COMMIT_REF_SLUG + paths: + - .cargo/registry + - target rules: - if: $CI_PIPELINE_SOURCE == "schedule" when: never diff --git a/build.rs b/build.rs index 0d99eb1..50d1b71 100644 --- a/build.rs +++ b/build.rs @@ -25,6 +25,14 @@ fn install_dir(shell: Shell) -> Option<(PathBuf, bool)> { } fn main() -> Result<(), Error> { + // naming any rerun-if condition replaces cargo's default of re-running the + // script whenever anything in the package changed, so everything that + // shapes a completion script has to be named here. without them an + // installed script goes stale against the binary it completes + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-changed=src/cli"); + println!("cargo::rerun-if-changed=Cargo.toml"); + println!("cargo::rerun-if-env-changed=SHELL_COMPLETIONS_DIR"); for shell in Shell::value_variants() { println!( @@ -40,7 +48,18 @@ fn main() -> Result<(), Error> { continue; }; - fs::create_dir_all(&dir)?; + // a build script's working directory is the package root, so a relative + // dir would quietly install into the source tree and one holding `..` + // somewhere else again. only a path that says where it means is taken + if requested && !dir.is_absolute() { + return Err(Error::other(format!( + "the completions directory must be an absolute path, not {}", + dir.display() + ))); + } + + fs::create_dir_all(&dir) + .map_err(|e| Error::other(format!("creating {}: {e}", dir.display())))?; let path = generate_to(*shell, &mut cmd, env!("CARGO_PKG_NAME"), &dir)?; if requested { diff --git a/src/cli/django.rs b/src/cli/django.rs index 0a4c8ad..5790ebd 100644 --- a/src/cli/django.rs +++ b/src/cli/django.rs @@ -9,7 +9,7 @@ pub enum Django { /// Start a bash session in a fresh django container Bash, - /// Prepare empty management command 'command' in app 'app' + /// Prepare an empty management command NAME in the app at APP MakeCommand { app: PathBuf, name: String }, /// Run Django's manage.py makemigrations diff --git a/src/commands/completions.rs b/src/commands/completions.rs index b7ffb0d..a8c1913 100644 --- a/src/commands/completions.rs +++ b/src/commands/completions.rs @@ -27,15 +27,24 @@ fn script(shell: Shell) -> Vec { #[cfg(test)] mod tests { - use super::{Shell, script}; - use clap::ValueEnum; + use super::{Ahab, Shell, script}; + use clap::{CommandFactory, ValueEnum}; #[test] fn every_shell_gets_a_script_covering_the_subcommands() { + // asked of the parser rather than listed here, which is a list that + // silently stops covering the newest command + let subcommands: Vec = Ahab::command() + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + assert!(subcommands.len() > 1, "{subcommands:?}"); + for shell in Shell::value_variants() { let out = String::from_utf8(script(*shell)).expect("script is utf8"); - for subcommand in ["django", "postgres", "link", "completions"] { + for subcommand in &subcommands { assert!( out.contains(subcommand), "{shell:?} script never mentions {subcommand}"