Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
27 changes: 24 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,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>` — Shell to print completions for (defaults to $SHELL)



Expand Down
179 changes: 23 additions & 156 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: &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)]
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,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());
}
}
64 changes: 60 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ Common workflow:
3. Fix the bug
4. Close the bug: detail bugs close <bug_id>";

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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
},

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