Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -222,9 +222,17 @@ 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 `source <(detail completions bash)` to your shell rc file (.bashrc, .zshrc, etc.) to enable tab completion. SHELL defaults to whatever is detected from $SHELL.

Supported shells: bash, zsh, fish, elvish, powershell.

**Usage:** `detail completions [SHELL]`

###### **Arguments:**

* `<SHELL>` — Shell to print completions for (defaults to $SHELL)



Expand Down
183 changes: 32 additions & 151 deletions src/commands/completions.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -25,67 +23,21 @@ fn snippet(shell: &str) -> Result<&'static str> {
}
}

fn rc_path(shell: &str) -> Result<PathBuf> {
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<W: Write>(shell: Option<&str>, out: &mut W) -> Result<()> {
let detected;
let shell = if let Some(s) = shell {
s
} else {
detected = detect_shell()?;
detected.as_str()
};
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 stdout = io::stdout();
print_snippet(shell, &mut stdout.lock())
}

#[cfg(test)]
Expand All @@ -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());
}

Expand All @@ -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");
Expand All @@ -151,95 +97,30 @@ mod tests {
}

#[test]
fn rc_path_zsh_is_zshrc() {
let rc = rc_path("zsh").unwrap();
assert!(rc.ends_with(".zshrc"));
}

#[test]
fn rc_path_fish_is_completions_dir() {
let rc = rc_path("fish").unwrap();
assert!(rc.ends_with("fish/completions/detail.fish"));
fn print_snippet_writes_explicit_shell_to_writer() {
let mut out = Vec::new();
print_snippet(Some("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_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()
);
fn print_snippet_uses_detected_shell_when_none() {
let original = env::var("SHELL").ok();
env::set_var("SHELL", "/bin/zsh");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
let mut out = Vec::new();
print_snippet(None, &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
assert_eq!(s, "source <(COMPLETE=zsh detail)\n");
match original {
Some(v) => env::set_var("PROFILE", v),
None => env::remove_var("PROFILE"),
Some(v) => env::set_var("SHELL", v),
None => env::remove_var("SHELL"),
}
}

#[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(Some("tcsh"), &mut out).is_err());
}
}
49 changes: 45 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,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
Expand Down Expand Up @@ -102,7 +106,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,
Expand Down Expand Up @@ -137,8 +141,17 @@ enum Commands {
command: commands::bugs::BugCommands,
},

/// Install shell completions (auto-detects your shell)
Completions,
/// Print shell completion script to stdout
///
/// Add `source <(detail completions bash)` to your shell rc file
/// (.bashrc, .zshrc, etc.) to enable tab completion. SHELL defaults
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the cross-shell completion example

This help text tells users to add the bash-specific invocation to any rc file, including .zshrc; in a zsh/fish/powershell setup that command still prints the bash activation snippet (COMPLETE=bash), so users following the generated help install the wrong completion script or a syntax that their shell cannot source. Please make the example shell-specific or use the auto-detected form where appropriate.

Useful? React with 👍 / 👎.

/// to whatever is detected from $SHELL.
///
/// Supported shells: bash, zsh, fish, elvish, powershell.
Completions {
/// Shell to print completions for (defaults to $SHELL)
shell: Option<String>,
},

/// Create and inspect rules
Rules {
Expand Down Expand Up @@ -343,6 +356,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();
Expand Down
Loading