diff --git a/docs/handbook.md b/docs/handbook.md index 3856b40c..95469e1d 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -400,9 +400,10 @@ lla completion zsh --output ./_lla lla completion zsh --path ~/.zsh/completions/_lla ``` -Without either option, the command installs to its shell-specific default file. -`--path` overrides that installed file; `--output` writes the generated script -to the requested file. Release packages may also include pre-generated assets. +Without either option, the command prints the script to stdout, so it can be +redirected or evaluated by your shell. `--path` installs to the specified file; +`--output` writes the generated script to the requested file. Release packages +may also include pre-generated assets. ### Shortcuts diff --git a/lla/src/commands/command_handler.rs b/lla/src/commands/command_handler.rs index 462b4122..f2505302 100644 --- a/lla/src/commands/command_handler.rs +++ b/lla/src/commands/command_handler.rs @@ -54,8 +54,12 @@ fn install_completion( let mut buf = Vec::new(); clap_complete::generate(shell, app, env!("CARGO_PKG_NAME"), &mut buf); - match output_path { - Some(path) => { + match (output_path, custom_path) { + (None, None) => { + std::io::stdout().lock().write_all(&buf)?; + Ok(()) + } + (Some(path), _) => { if let Some(parent) = std::path::Path::new(path).parent() { create_dir_all(parent)?; } @@ -72,75 +76,14 @@ fn install_completion( } Ok(()) } - None => { - let (install_path, post_install_msg) = if let Some(path) = custom_path { - ( - std::path::PathBuf::from(path), - "Restart your shell to apply changes", - ) - } else { - match shell { - clap_complete::Shell::Bash => { - let path = dirs::home_dir() - .map(|h| h.join(".local/share/bash-completion/completions")) - .ok_or_else(|| { - LlaError::Other("Could not determine home directory".into()) - })?; - ( - path.join("lla"), - "Restart your shell or run 'source ~/.bashrc'", - ) - } - clap_complete::Shell::Fish => { - let path = dirs::home_dir() - .map(|h| h.join(".config/fish/completions")) - .ok_or_else(|| { - LlaError::Other("Could not determine home directory".into()) - })?; - ( - path.join("lla.fish"), - "Restart your shell or run 'source ~/.config/fish/config.fish'", - ) - } - clap_complete::Shell::Zsh => { - let path = dirs::home_dir() - .map(|h| h.join(".zsh/completions")) - .ok_or_else(|| { - LlaError::Other("Could not determine home directory".into()) - })?; - ( - path.join("_lla"), - "Add 'fpath=(~/.zsh/completions $fpath)' to ~/.zshrc and restart your shell", - ) - } - clap_complete::Shell::PowerShell => { - let path = dirs::home_dir() - .map(|h| h.join("Documents/WindowsPowerShell")) - .ok_or_else(|| { - LlaError::Other("Could not determine home directory".into()) - })?; - ( - path.join("lla.ps1"), - "Restart PowerShell or reload your profile", - ) - } - clap_complete::Shell::Elvish => { - let path = - dirs::home_dir() - .map(|h| h.join(".elvish/lib")) - .ok_or_else(|| { - LlaError::Other("Could not determine home directory".into()) - })?; - (path.join("lla.elv"), "Restart your shell") - } - _ => return Err(LlaError::Other(format!("Unsupported shell: {:?}", shell))), - } - }; + (None, Some(path)) => { + let install_path = std::path::Path::new(path); + let post_install_msg = "Restart your shell to apply changes"; if let Some(parent) = install_path.parent() { create_dir_all(parent)?; } - fs::write(&install_path, buf)?; + fs::write(install_path, buf)?; if color_state.is_enabled() { println!( diff --git a/lla/tests/completions.rs b/lla/tests/completions.rs new file mode 100644 index 00000000..d843c4d7 --- /dev/null +++ b/lla/tests/completions.rs @@ -0,0 +1,68 @@ +use std::fs; +use std::process::{Command, Output}; +use tempfile::TempDir; + +fn completion(home: &TempDir, shell: &str, options: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_lla")) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("APPDATA", home.path().join("AppData/Roaming")) + .env("LOCALAPPDATA", home.path().join("AppData/Local")) + .env("XDG_CONFIG_HOME", home.path().join(".config")) + .env("NO_COLOR", "1") + .current_dir(home.path()) + .args(["completion", shell]) + .args(options) + .output() + .expect("run completion command") +} + +#[test] +fn completions_default_to_stdout_without_installing_files() { + for shell in ["bash", "fish", "zsh", "powershell", "elvish"] { + let home = TempDir::new().unwrap(); + let output = completion(&home, shell, &[]); + assert!(output.status.success(), "{shell}: {:?}", output); + let script = String::from_utf8(output.stdout).unwrap(); + assert!(script.contains("lla"), "{shell}: missing completion script"); + assert!(!script.contains("✓"), "{shell}: status message in script"); + assert!(output.stderr.is_empty(), "{shell}: {:?}", output.stderr); + for path in [ + ".local/share/bash-completion/completions/lla", + ".config/fish/completions/lla.fish", + ".zsh/completions/_lla", + "Documents/WindowsPowerShell/lla.ps1", + ".elvish/lib/lla.elv", + ] { + assert!(!home.path().join(path).exists(), "{shell}: created {path}"); + } + } +} + +#[test] +fn explicit_paths_write_the_same_script_as_stdout() { + for shell in ["bash", "fish", "zsh", "powershell", "elvish"] { + let home = TempDir::new().unwrap(); + let expected = completion(&home, shell, &[]); + assert!(expected.status.success()); + for option in ["--output", "--path"] { + let path = home.path().join(format!("nested/{shell}/{option}")); + let output = completion(&home, shell, &[option, path.to_str().unwrap()]); + assert!(output.status.success(), "{shell} {option}: {:?}", output); + assert_eq!(fs::read(path).unwrap(), expected.stdout); + } + } +} + +#[test] +fn output_takes_precedence_over_install_path() { + let home = TempDir::new().unwrap(); + let output = completion( + &home, + "zsh", + &["--path", "install/_lla", "--output", "output/_lla"], + ); + assert!(output.status.success(), "{:?}", output); + assert!(home.path().join("output/_lla").is_file()); + assert!(!home.path().join("install").exists()); +}