diff --git a/docs/HELP.md b/docs/HELP.md index 4c8e01f..6c81514 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -50,7 +50,7 @@ Common workflow: * `auth` — Manage login credentials * `bugs` — List, show, and close bugs -* `completions` — Install shell completions (auto-detects your shell) +* `completions` — Print shell completion script to stdout * `rules` — Create and inspect rules * `satisfying-sort` — Run a fun animation. Humans only * `repos` — Manage repos tracked with Detail @@ -222,9 +222,30 @@ Reopen a previously resolved or dismissed bug — flips it back to pending. Usef ## `detail completions` -Install shell completions (auto-detects your shell) +Print shell completion script to stdout. -**Usage:** `detail completions` +Add the appropriate line to your shell's startup file: + + bash (~/.bashrc): + source <(detail completions bash) + + zsh (~/.zshrc): + source <(detail completions zsh) + + fish (~/.config/fish/config.fish): + detail completions fish | source + + powershell ($PROFILE): + detail completions powershell | Out-String | Invoke-Expression + +SHELL defaults to whatever is detected from $SHELL. Supported shells: +bash, zsh, fish, elvish, powershell. + +**Usage:** `detail completions [SHELL]` + +###### **Arguments:** + +* `` — Shell to print completions for (defaults to $SHELL) diff --git a/src/commands/completions.rs b/src/commands/completions.rs index 48e73ba..ec5d3df 100644 --- a/src/commands/completions.rs +++ b/src/commands/completions.rs @@ -1,7 +1,5 @@ use std::env; -use std::fs; -use std::io::Write; -use std::path::PathBuf; +use std::io::{self, Write}; use anyhow::{bail, Context, Result}; @@ -25,67 +23,21 @@ fn snippet(shell: &str) -> Result<&'static str> { } } -fn rc_path(shell: &str) -> Result { - let home = homedir::my_home()?.context("could not determine home directory")?; - match shell { - "bash" => { - let bashrc = home.join(".bashrc"); - let profile = home.join(".bash_profile"); - // Prefer .bashrc if it exists, else .bash_profile, else create .bashrc - if bashrc.exists() { - Ok(bashrc) - } else if profile.exists() { - Ok(profile) - } else { - Ok(bashrc) - } - } - "zsh" => Ok(home.join(".zshrc")), - "fish" => Ok(home.join(".config/fish/completions/detail.fish")), - "elvish" => { - let config_dir = - env::var("XDG_CONFIG_HOME").map_or_else(|_| home.join(".config"), PathBuf::from); - Ok(config_dir.join("elvish/rc.elv")) - } - "powershell" | "pwsh" => { - Ok(home.join(".config/powershell/Microsoft.PowerShell_profile.ps1")) - } - _ => bail!("unsupported shell: {shell}"), - } +fn print_snippet(shell: &str, out: &mut W) -> Result<()> { + writeln!(out, "{}", snippet(shell)?)?; + Ok(()) } -pub fn handle() -> Result<()> { - let shell = detect_shell()?; - let snippet = snippet(&shell)?; - let rc = rc_path(&shell)?; - - // Check if already installed - if rc.exists() { - let contents = fs::read_to_string(&rc)?; - if contents.contains(snippet) { - console::Term::stderr().write_line(&format!( - "Completions already installed in {}", - rc.display(), - ))?; - return Ok(()); - } - } - - // Ensure parent directory exists (relevant for fish/elvish/powershell) - if let Some(parent) = rc.parent() { - fs::create_dir_all(parent)?; - } - - let mut file = fs::OpenOptions::new().create(true).append(true).open(&rc)?; - writeln!(file)?; - writeln!(file, "# Detail CLI shell completions")?; - writeln!(file, "{snippet}")?; - - console::Term::stderr().write_line(&format!( - "Installed completions in {} — restart your shell or run:\n {snippet}", - rc.display(), - ))?; - Ok(()) +pub fn handle(shell: Option<&str>) -> Result<()> { + let detected; + let shell = if let Some(s) = shell { + s + } else { + detected = detect_shell()?; + detected.as_str() + }; + let stdout = io::stdout(); + print_snippet(shell, &mut stdout.lock()) } #[cfg(test)] @@ -94,36 +46,31 @@ mod tests { #[test] fn snippet_bash() { - assert!(snippet("bash").is_ok()); assert!(snippet("bash").unwrap().contains("COMPLETE=bash")); } #[test] fn snippet_zsh() { - assert!(snippet("zsh").is_ok()); assert!(snippet("zsh").unwrap().contains("COMPLETE=zsh")); } #[test] fn snippet_fish() { - assert!(snippet("fish").is_ok()); assert!(snippet("fish").unwrap().contains("COMPLETE=fish")); } #[test] fn snippet_elvish() { - assert!(snippet("elvish").is_ok()); assert!(snippet("elvish").unwrap().contains("COMPLETE=elvish")); } #[test] fn snippet_powershell() { - assert!(snippet("powershell").is_ok()); assert!(snippet("powershell").unwrap().contains("COMPLETE")); } #[test] - fn snippet_pwsh() { + fn snippet_pwsh_matches_powershell() { assert_eq!(snippet("pwsh").unwrap(), snippet("powershell").unwrap()); } @@ -134,7 +81,6 @@ mod tests { #[test] fn detect_shell_from_env() { - // Save and restore $SHELL let original = env::var("SHELL").ok(); env::set_var("SHELL", "/usr/bin/zsh"); assert_eq!(detect_shell().unwrap(), "zsh"); @@ -151,95 +97,16 @@ mod tests { } #[test] - fn rc_path_zsh_is_zshrc() { - let rc = rc_path("zsh").unwrap(); - assert!(rc.ends_with(".zshrc")); + fn print_snippet_writes_to_writer() { + let mut out = Vec::new(); + print_snippet("bash", &mut out).unwrap(); + let s = String::from_utf8(out).unwrap(); + assert_eq!(s, "eval \"$(COMPLETE=bash detail 2>/dev/null)\"\n"); } #[test] - fn rc_path_fish_is_completions_dir() { - let rc = rc_path("fish").unwrap(); - assert!(rc.ends_with("fish/completions/detail.fish")); - } - - #[test] - fn rc_path_elvish_is_rc_elv() { - let rc = rc_path("elvish").unwrap(); - assert!( - rc.ends_with(".config/elvish/rc.elv"), - "expected XDG-compliant elvish path, got: {}", - rc.display() - ); - } - - #[test] - fn rc_path_unsupported_errors() { - assert!(rc_path("tcsh").is_err()); - } - - #[test] - fn rc_path_bash_prefers_bashrc_when_exists() { - let dir = env::temp_dir().join(format!("detail-test-bash-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - - // Create .bashrc - fs::write(dir.join(".bashrc"), "").unwrap(); - - // We can't easily override homedir::my_home(), so this test - // just verifies the function returns a path ending in .bashrc - // when called normally. The real fallback logic is tested via - // the integration of the function. - let rc = rc_path("bash").unwrap(); - // On any system, bash rc_path returns either .bashrc or .bash_profile - let name = rc.file_name().unwrap().to_str().unwrap(); - assert!( - name == ".bashrc" || name == ".bash_profile", - "unexpected bash rc path: {name}" - ); - - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn rc_path_powershell_and_pwsh_equivalent() { - // Both "powershell" and "pwsh" should resolve to the same path - let ps = rc_path("powershell").unwrap(); - let pwsh = rc_path("pwsh").unwrap(); - assert_eq!(ps, pwsh); - } - - #[test] - fn rc_path_powershell_ignores_profile_env_var() { - // PROFILE is a Windows system env var (user profile dir), NOT the - // PowerShell $PROFILE automatic variable. It must not affect the path. - let original = env::var("PROFILE").ok(); - env::set_var("PROFILE", "/wrong/path"); - let rc = rc_path("powershell").unwrap(); - assert!( - rc.ends_with("powershell/Microsoft.PowerShell_profile.ps1"), - "PROFILE env var should not affect PowerShell rc path, got: {}", - rc.display() - ); - match original { - Some(v) => env::set_var("PROFILE", v), - None => env::remove_var("PROFILE"), - } - } - - #[test] - fn rc_path_elvish_respects_xdg_config_home() { - let original = env::var("XDG_CONFIG_HOME").ok(); - env::set_var("XDG_CONFIG_HOME", "/custom/config"); - let rc = rc_path("elvish").unwrap(); - assert_eq!( - rc, - PathBuf::from("/custom/config/elvish/rc.elv"), - "elvish should respect XDG_CONFIG_HOME" - ); - match original { - Some(v) => env::set_var("XDG_CONFIG_HOME", v), - None => env::remove_var("XDG_CONFIG_HOME"), - } + fn print_snippet_unsupported_shell_errors() { + let mut out = Vec::new(); + assert!(print_snippet("tcsh", &mut out).is_err()); } } diff --git a/src/lib.rs b/src/lib.rs index 73cb9fa..3307a0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,26 @@ Common workflow: 3. Fix the bug 4. Close the bug: detail bugs close "; +const COMPLETIONS_LONG_ABOUT: &str = "\ +Print shell completion script to stdout. + +Add the appropriate line to your shell's startup file: + + bash (~/.bashrc): + source <(detail completions bash) + + zsh (~/.zshrc): + source <(detail completions zsh) + + fish (~/.config/fish/config.fish): + detail completions fish | source + + powershell ($PROFILE): + detail completions powershell | Out-String | Invoke-Expression + +SHELL defaults to whatever is detected from $SHELL. Supported shells: +bash, zsh, fish, elvish, powershell."; + #[derive(Parser)] #[command(name = "detail")] #[command(version = VERSION)] @@ -73,8 +93,12 @@ impl Cli { | commands::rules::RuleCommands::Show { .. } | commands::rules::RuleCommands::Pull { .. } => false, }, + // Completions prints a shell snippet that may be sourced via + // `source <(detail completions bash)` from the user's rc file, so + // any auto-update notice on stderr would surface on every shell + // startup — keep this silent. + Commands::Completions { .. } => true, Commands::Auth { .. } - | Commands::Completions | Commands::SatisfyingSort | Commands::Skill { .. } | Commands::Update @@ -102,7 +126,7 @@ impl Cli { match &self.command { Commands::Auth { command } => commands::auth::handle(command, &self).await, Commands::Bugs { command } => commands::bugs::handle(command, &self).await, - Commands::Completions => commands::completions::handle(), + Commands::Completions { shell } => commands::completions::handle(shell.as_deref()), Commands::Rules { command } => commands::rules::handle(command, &self).await, Commands::SatisfyingSort => commands::satisfying_sort::handle().await, Commands::Repos { command } => commands::repos::handle(command, &self).await, @@ -137,8 +161,12 @@ enum Commands { command: commands::bugs::BugCommands, }, - /// Install shell completions (auto-detects your shell) - Completions, + /// Print shell completion script to stdout + #[command(long_about = COMPLETIONS_LONG_ABOUT)] + Completions { + /// Shell to print completions for (defaults to $SHELL) + shell: Option, + }, /// Create and inspect rules Rules { @@ -343,6 +371,34 @@ mod tests { assert!(!cli.is_silent()); } + #[test] + fn completions_accepts_optional_shell_arg() { + let cli = Cli::try_parse_from(["detail", "completions", "bash"]).unwrap(); + if let Commands::Completions { shell } = &cli.command { + assert_eq!(shell.as_deref(), Some("bash")); + } else { + panic!("expected completions command"); + } + } + + #[test] + fn completions_shell_arg_optional() { + let cli = Cli::try_parse_from(["detail", "completions"]).unwrap(); + if let Commands::Completions { shell } = &cli.command { + assert!(shell.is_none()); + } else { + panic!("expected completions command"); + } + } + + #[test] + fn silent_for_completions() { + // Output is sourced by shell rc files via `source <(detail completions bash)`, + // so auto-update notices must stay off. + let cli = Cli::try_parse_from(["detail", "completions"]).unwrap(); + assert!(cli.is_silent()); + } + #[test] fn not_silent_for_satisfying_sort() { let cli = Cli::try_parse_from(["detail", "satisfying-sort"]).unwrap();