diff --git a/Cargo.lock b/Cargo.lock index 3582f557..8062ac5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,7 @@ dependencies = [ "bytes", "chrono", "clap", + "clap_complete", "crossterm", "directories", "envd", @@ -1479,6 +1480,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -2073,7 +2083,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5884,7 +5894,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", diff --git a/crates/aenv/Cargo.toml b/crates/aenv/Cargo.toml index 888a83fb..b74c6e59 100644 --- a/crates/aenv/Cargo.toml +++ b/crates/aenv/Cargo.toml @@ -12,6 +12,7 @@ anyhow = "1" bytes = "1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } +clap_complete = "4" crossterm = "0.28" directories = "5" envd = { path = "../../thirdparty/envd" } diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs new file mode 100644 index 00000000..28648255 --- /dev/null +++ b/crates/aenv/src/commands/completion.rs @@ -0,0 +1,229 @@ +use anyhow::Context; +use anyhow::Result; +use clap::Args as ClapArgs; +use clap::CommandFactory; +use clap::ValueEnum; +use clap_complete::Shell as ClapShell; +use std::io::Write; + +/// Shell to generate completion for. +/// +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum Shell { + Bash, + Zsh, + Fish, +} + +impl From for ClapShell { + fn from(shell: Shell) -> Self { + match shell { + Shell::Bash => ClapShell::Bash, + Shell::Zsh => ClapShell::Zsh, + Shell::Fish => ClapShell::Fish, + } + } +} + +#[derive(ClapArgs)] +pub struct Args { + /// Shell to generate completion for. + #[arg(value_enum)] + pub shell: Shell, +} + +/// Generate a completion script for the requested shell and write it to stdout. +pub fn run(args: Args) -> Result<()> { + write_completion(args.shell, &mut std::io::stdout().lock()) +} + +/// Generate the completion script for `shell` and write it to `out`. +/// +/// Generation goes through an in-memory buffer first: clap_complete's +/// generators panic on write errors (`Generator::generate` calls `.expect`), +/// so writing straight to `out` would turn a closed downstream pipe into a +/// panic. The buffer cannot fail, so generation is infallible; only the +/// explicit write below can. A `BrokenPipe` there (e.g. `aenv completion bash +/// | head`) is normal and treated as success; any other error propagates with +/// context. Split out from `run` so the write branches are unit-testable. +fn write_completion(shell: Shell, out: &mut W) -> Result<()> { + let mut cmd = crate::Cli::command(); + let mut script = Vec::new(); + clap_complete::generate(ClapShell::from(shell), &mut cmd, "aenv", &mut script); + match out.write_all(&script).and_then(|_| out.flush()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Err(err) => Err(err).context("writing completion script to stdout"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generate_for(shell: Shell) -> String { + let mut buf = Vec::new(); + write_completion(shell, &mut buf).expect("writing to a Vec cannot fail"); + String::from_utf8(buf).expect("completion output is valid UTF-8") + } + + /// Writer that always fails with a configured error kind, for exercising + /// `write_completion`'s error branches. + struct FailingWriter { + kind: std::io::ErrorKind, + fail_on_flush: bool, + } + + impl std::io::Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.fail_on_flush { + Ok(buf.len()) + } else { + Err(std::io::Error::from(self.kind)) + } + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::from(self.kind)) + } + } + + #[test] + fn bash_has_compdef_or_complete_f() { + let s = generate_for(Shell::Bash); + assert!( + s.contains("complete -F") || s.contains("compdef"), + "bash output should register the binary; got:\n{s}" + ); + } + + #[test] + fn zsh_has_compdef_header() { + let s = generate_for(Shell::Zsh); + assert!( + s.starts_with("#compdef"), + "zsh output should start with a #compdef header; got:\n{s}" + ); + } + + #[test] + fn fish_has_complete_calls() { + let s = generate_for(Shell::Fish); + assert!( + s.contains("complete "), + "fish output should contain `complete` invocations; got:\n{s}" + ); + } + + #[test] + fn connect_exposes_cn_alias() { + // Assert on the Command tree, not on clap_complete's generated bash + // dispatch format: the internal `aenv,cn)` / `__subcmd__` naming is an + // implementation detail a compatible clap_complete upgrade could rename + // even though completion still works. If `connect` declares `cn` as a + // visible alias, clap_complete emits it — that contract is ours. + let cmd = crate::Cli::command(); + let connect = cmd + .find_subcommand("connect") + .expect("`connect` command exists"); + assert!( + connect.get_visible_aliases().any(|a| a == "cn"), + "`connect` should declare `cn` as a visible alias" + ); + } + + #[test] + fn snapshot_exposes_create_subcommand() { + // See `connect_exposes_cn_alias`: assert on the Command tree, not on + // clap_complete's internal bash helper naming. + let cmd = crate::Cli::command(); + let snapshot = cmd + .find_subcommand("snapshot") + .expect("`snapshot` command exists"); + assert!( + snapshot.find_subcommand("create").is_some(), + "`snapshot` should expose a `create` subcommand" + ); + } + + #[test] + fn output_arg_offers_table_and_json() { + // Assert on the argument metadata: the exact `compgen -W "table json"` + // string is clap_complete's bash formatting, which a compatible upgrade + // could change. The possible values are defined on the `--output` arg. + let cmd = crate::Cli::command(); + let list = cmd.find_subcommand("list").expect("`list` command exists"); + let output = list + .get_arguments() + .find(|a| a.get_long() == Some("output")) + .expect("`list` should declare an --output argument"); + let possible = output.get_possible_values(); + let names: Vec<&str> = possible.iter().map(|v| v.get_name()).collect(); + assert!( + names.contains(&"table") && names.contains(&"json"), + "--output should offer table and json; got {names:?}" + ); + } + + #[test] + fn write_completion_succeeds_into_buffer() { + let mut buf: Vec = Vec::new(); + write_completion(Shell::Bash, &mut buf).expect("Vec write succeeds"); + assert!( + !buf.is_empty(), + "a completion script should have been written" + ); + } + + #[test] + fn broken_pipe_is_treated_as_success() { + // `aenv completion bash | head` closes the pipe early; that must exit + // cleanly rather than error or panic. + let mut out = FailingWriter { + kind: std::io::ErrorKind::BrokenPipe, + fail_on_flush: false, + }; + write_completion(Shell::Bash, &mut out) + .expect("BrokenPipe during completion output should not error"); + } + + #[test] + fn broken_pipe_on_flush_is_treated_as_success() { + let mut out = FailingWriter { + kind: std::io::ErrorKind::BrokenPipe, + fail_on_flush: true, + }; + write_completion(Shell::Bash, &mut out) + .expect("BrokenPipe during completion flush should not error"); + } + + #[test] + fn other_io_error_propagates() { + let mut out = FailingWriter { + kind: std::io::ErrorKind::Other, + fail_on_flush: false, + }; + let err = write_completion(Shell::Bash, &mut out) + .expect_err("non-BrokenPipe I/O errors should propagate"); + assert!( + err.to_string() + .contains("writing completion script to stdout"), + "error should carry completion context; got: {err}" + ); + } + + #[test] + fn other_io_error_on_flush_propagates() { + let mut out = FailingWriter { + kind: std::io::ErrorKind::Other, + fail_on_flush: true, + }; + let err = + write_completion(Shell::Bash, &mut out).expect_err("flush errors should propagate"); + assert!( + err.to_string() + .contains("writing completion script to stdout"), + "error should carry completion context; got: {err}" + ); + } +} diff --git a/crates/aenv/src/commands/mod.rs b/crates/aenv/src/commands/mod.rs index 47b54df9..b3de6cc5 100644 --- a/crates/aenv/src/commands/mod.rs +++ b/crates/aenv/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod build; +pub mod completion; pub mod connect; pub mod delete; pub mod download; diff --git a/crates/aenv/src/commands/snapshot.rs b/crates/aenv/src/commands/snapshot.rs index 4935492e..b3426a6a 100644 --- a/crates/aenv/src/commands/snapshot.rs +++ b/crates/aenv/src/commands/snapshot.rs @@ -27,7 +27,7 @@ enum Sub { name: Option, }, /// List persistent snapshots - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List { /// Filter snapshots by source sandbox ID #[arg(long = "sandbox-id")] diff --git a/crates/aenv/src/commands/template.rs b/crates/aenv/src/commands/template.rs index 064b77b5..5d409191 100644 --- a/crates/aenv/src/commands/template.rs +++ b/crates/aenv/src/commands/template.rs @@ -20,13 +20,13 @@ pub struct Args { #[derive(Subcommand)] enum Sub { /// List all templates - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List { #[arg(long, value_enum)] output: Option, }, /// Delete a template by ID or name - #[command(alias = "rm")] + #[command(visible_alias = "rm")] Delete { template: String }, /// Watch a template build until it succeeds or fails Watch { template: String }, diff --git a/crates/aenv/src/main.rs b/crates/aenv/src/main.rs index fa3b21b3..beb38218 100644 --- a/crates/aenv/src/main.rs +++ b/crates/aenv/src/main.rs @@ -33,26 +33,28 @@ enum Cmd { Upload(commands::upload::Args), /// Download a file from a sandbox Download(commands::download::Args), + /// Generate shell completion scripts + Completion(commands::completion::Args), /// Attach an interactive shell to a running sandbox - #[command(alias = "cn")] + #[command(visible_alias = "cn")] Connect(commands::connect::Args), /// Pause a running sandbox Pause(commands::pause::Args), /// Resume a paused sandbox Resume(commands::resume::Args), /// List sandboxes - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List(commands::list::Args), /// Kill a sandbox - #[command(alias = "rm")] + #[command(visible_alias = "rm")] Delete(commands::delete::Args), /// Set the sandbox expiration (seconds from now) Timeout(commands::timeout::Args), /// Snapshot operations - #[command(alias = "snap")] + #[command(visible_alias = "snap")] Snapshot(commands::snapshot::Args), /// Template operations - #[command(alias = "templates")] + #[command(visible_alias = "templates")] Template(commands::template::Args), } @@ -66,6 +68,7 @@ fn main() -> Result<()> { Cmd::Exec(a) => commands::exec::run(a), Cmd::Upload(a) => commands::upload::run(a), Cmd::Download(a) => commands::download::run(a), + Cmd::Completion(a) => commands::completion::run(a), Cmd::Connect(a) => commands::connect::run(a), Cmd::Pause(a) => commands::pause::run(a), Cmd::Resume(a) => commands::resume::run(a), diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 53104f5b..49eab61d 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -274,3 +274,68 @@ aenv snapshot list --sandbox-id The table output includes an `IMAGE REF` column (`-` when no image was published); JSON output includes the optional `imageRef` field. To delete a snapshot, use `aenv template delete ` or `aenv template delete ` — snapshots share the same underlying store as templates and are deleted through the same command. + +--- + +## Shell completion + +`aenv completion ` prints a shell-completion script for the `aenv` CLI to stdout. + +### Generate and install a script + +#### Bash + +```bash +mkdir -p ~/.local/share/bash-completion/completions +aenv completion bash > ~/.local/share/bash-completion/completions/aenv +``` + +The Bash completion file is loaded on demand by +[`bash-completion`](https://github.com/scop/bash-completion). +This requires `bash-completion` to be installed and initialized in the current +shell. + +#### Zsh + +```zsh +mkdir -p ~/.local/share/zsh/site-functions +aenv completion zsh > ~/.local/share/zsh/site-functions/_aenv +``` + +Zsh loads completion functions from directories listed in `fpath`. +`~/.local/share/zsh/site-functions` is not included in `fpath` by default on +all systems. Add the following lines to `~/.zshrc` before any existing +`compinit` invocation: + +```zsh +fpath=(~/.local/share/zsh/site-functions $fpath) +autoload -Uz compinit +compinit +``` + +#### Fish + +```shell +mkdir -p ~/.config/fish/completions +aenv completion fish > ~/.config/fish/completions/aenv.fish +``` + +### Activate without installing + +To test completion for the current shell session without saving a generated +file: + +```bash +source <(aenv completion bash) # Bash +eval "$(aenv completion zsh)" # Zsh +aenv completion fish | source # Fish +``` + +Once loaded, completion covers the CLI surface: + +```bash +aenv # top-level commands +aenv snapshot # nested subcommands (create, list, ...) +aenv list --output # enum values: table, json +aenv build ./ # local path arguments +```