Skip to main content

gn_cli/commands/
completions.rs

1use anyhow::{bail, Context, Result};
2use clap::{Args, CommandFactory};
3use clap_complete::{generate, Shell};
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8#[derive(Args, Debug, Clone)]
9pub struct CompletionsArgs {
10    /// Shell to generate completions for (bash, zsh, fish, powershell, elvish)
11    #[arg(value_enum)]
12    pub shell: Option<Shell>,
13
14    /// Automatically install completions into standard shell configuration directory
15    #[arg(long)]
16    pub install: bool,
17}
18
19pub fn run(args: &CompletionsArgs) -> Result<()> {
20    if args.install {
21        install_completions(args.shell)?;
22    } else if let Some(shell) = args.shell {
23        let mut cmd = crate::Cli::command();
24        // Generate completions for git-notes and gn binary
25        generate(shell, &mut cmd, "git-notes", &mut io::stdout());
26        let mut cmd_gn = crate::Cli::command();
27        generate(shell, &mut cmd_gn, "gn", &mut io::stdout());
28    } else {
29        bail!("Please specify a shell (bash, zsh, fish, powershell, elvish) or use --install");
30    }
31    Ok(())
32}
33
34/// Detect user's current shell from environment variables
35pub fn detect_shell() -> Option<Shell> {
36    if let Ok(shell_env) = std::env::var("SHELL") {
37        let lower = shell_env.to_lowercase();
38        if lower.contains("zsh") {
39            return Some(Shell::Zsh);
40        } else if lower.contains("fish") {
41            return Some(Shell::Fish);
42        } else if lower.contains("bash") {
43            return Some(Shell::Bash);
44        } else if lower.contains("elvish") {
45            return Some(Shell::Elvish);
46        }
47    }
48
49    #[cfg(windows)]
50    {
51        if std::env::var_os("PSModulePath").is_some() {
52            return Some(Shell::PowerShell);
53        }
54    }
55
56    None
57}
58
59/// Helper to get home directory across Linux, macOS, and Windows
60fn home_dir() -> Option<PathBuf> {
61    std::env::var_os("USERPROFILE")
62        .or_else(|| std::env::var_os("HOME"))
63        .map(PathBuf::from)
64}
65
66/// Generate completion script bytes for both `git-notes` and `gn`
67pub fn generate_completion_script(shell: Shell) -> Vec<u8> {
68    let mut buf = Vec::new();
69    let mut cmd_git_notes = crate::Cli::command();
70    generate(shell, &mut cmd_git_notes, "git-notes", &mut buf);
71    buf.push(b'\n');
72    let mut cmd_gn = crate::Cli::command();
73    generate(shell, &mut cmd_gn, "gn", &mut buf);
74    buf
75}
76
77/// Automatically detect shell and install completion script
78pub fn install_completions(explicit_shell: Option<Shell>) -> Result<()> {
79    let shell = match explicit_shell.or_else(detect_shell) {
80        Some(s) => s,
81        None => {
82            #[cfg(windows)]
83            {
84                Shell::PowerShell
85            }
86            #[cfg(not(windows))]
87            {
88                bail!("Could not automatically detect shell. Please specify one explicitly, e.g.: gn completions zsh --install");
89            }
90        }
91    };
92
93    let home = home_dir().context("Failed to determine user home directory")?;
94    let script_bytes = generate_completion_script(shell);
95
96    match shell {
97        Shell::Zsh => {
98            // ~/.zsh/completions/_git-notes and _gn
99            let zsh_comp_dir = home.join(".zsh").join("completions");
100            fs::create_dir_all(&zsh_comp_dir)
101                .with_context(|| format!("Failed to create directory {}", zsh_comp_dir.display()))?;
102
103            let mut gn_buf = Vec::new();
104            let mut cmd_gn = crate::Cli::command();
105            generate(Shell::Zsh, &mut cmd_gn, "gn", &mut gn_buf);
106            let gn_path = zsh_comp_dir.join("_gn");
107            fs::write(&gn_path, gn_buf)?;
108
109            let mut git_notes_buf = Vec::new();
110            let mut cmd_git_notes = crate::Cli::command();
111            generate(Shell::Zsh, &mut cmd_git_notes, "git-notes", &mut git_notes_buf);
112            let git_notes_path = zsh_comp_dir.join("_git-notes");
113            fs::write(&git_notes_path, git_notes_buf)?;
114
115            println!("\x1b[32m✓ Installed Zsh completions:\x1b[0m");
116            println!("  • {}", gn_path.display());
117            println!("  • {}", git_notes_path.display());
118            println!("\n\x1b[90mEnsure your ~/.zshrc contains:\x1b[0m");
119            println!("  fpath=(~/.zsh/completions $fpath)");
120            println!("  autoload -Uz compinit && compinit");
121        }
122        Shell::Bash => {
123            // ~/.bash_completion.d/gn or ~/.local/share/bash-completion/completions/gn
124            let bash_comp_dir = home.join(".bash_completion.d");
125            fs::create_dir_all(&bash_comp_dir)
126                .with_context(|| format!("Failed to create directory {}", bash_comp_dir.display()))?;
127            let target_path = bash_comp_dir.join("gn");
128            fs::write(&target_path, script_bytes)?;
129
130            println!("\x1b[32m✓ Installed Bash completions:\x1b[0m");
131            println!("  • {}", target_path.display());
132            println!("\n\x1b[90mEnsure ~/.bashrc sources ~/.bash_completion.d:\x1b[0m");
133            println!("  for f in ~/.bash_completion.d/*; do [[ -f \"$f\" ]] && source \"$f\"; done");
134        }
135        Shell::Fish => {
136            // ~/.config/fish/completions/gn.fish & git-notes.fish
137            let fish_comp_dir = home.join(".config").join("fish").join("completions");
138            fs::create_dir_all(&fish_comp_dir)
139                .with_context(|| format!("Failed to create directory {}", fish_comp_dir.display()))?;
140
141            let mut gn_buf = Vec::new();
142            let mut cmd_gn = crate::Cli::command();
143            generate(Shell::Fish, &mut cmd_gn, "gn", &mut gn_buf);
144            let gn_path = fish_comp_dir.join("gn.fish");
145            fs::write(&gn_path, gn_buf)?;
146
147            let mut git_notes_buf = Vec::new();
148            let mut cmd_git_notes = crate::Cli::command();
149            generate(Shell::Fish, &mut cmd_git_notes, "git-notes", &mut git_notes_buf);
150            let git_notes_path = fish_comp_dir.join("git-notes.fish");
151            fs::write(&git_notes_path, git_notes_buf)?;
152
153            println!("\x1b[32m✓ Installed Fish completions:\x1b[0m");
154            println!("  • {}", gn_path.display());
155            println!("  • {}", git_notes_path.display());
156        }
157        Shell::PowerShell => {
158            // Append to PowerShell Profile or create ~/.git-notes/completions.ps1 and dot-source in profile
159            let profile_path = get_powershell_profile_path(&home);
160            if let Some(parent) = profile_path.parent() {
161                let _ = fs::create_dir_all(parent);
162            }
163
164            let comp_dir = home.join(".git-notes").join("completions");
165            fs::create_dir_all(&comp_dir)?;
166            let script_path = comp_dir.join("gn-completions.ps1");
167            fs::write(&script_path, &script_bytes)?;
168
169            let source_cmd = format!("\n. \"{}\"\n", script_path.display());
170            let current_content = fs::read_to_string(&profile_path).unwrap_or_default();
171            if !current_content.contains("gn-completions.ps1") {
172                let mut new_content = current_content;
173                new_content.push_str(&source_cmd);
174                fs::write(&profile_path, new_content)?;
175            }
176
177            println!("\x1b[32m✓ Installed PowerShell completions:\x1b[0m");
178            println!("  • Script: {}", script_path.display());
179            println!("  • Profile: {}", profile_path.display());
180        }
181        Shell::Elvish => {
182            // ~/.elvish/lib/gn.elv
183            let elvish_comp_dir = home.join(".elvish").join("lib");
184            fs::create_dir_all(&elvish_comp_dir)
185                .with_context(|| format!("Failed to create directory {}", elvish_comp_dir.display()))?;
186            let target_path = elvish_comp_dir.join("gn.elv");
187            fs::write(&target_path, script_bytes)?;
188
189            println!("\x1b[32m✓ Installed Elvish completions:\x1b[0m");
190            println!("  • {}", target_path.display());
191        }
192        _ => {
193            bail!("Automated installation not yet supported for {:?}. Please generate output directly with: gn completions {:?}", shell, shell);
194        }
195    }
196
197    Ok(())
198}
199
200fn get_powershell_profile_path(home: &Path) -> PathBuf {
201    #[cfg(windows)]
202    {
203        // Check Documents\PowerShell\Microsoft.PowerShell_profile.ps1 or Documents\WindowsPowerShell\...
204        let docs = home.join("Documents");
205        let ps7 = docs.join("PowerShell").join("Microsoft.PowerShell_profile.ps1");
206        if ps7.exists() || !docs.join("WindowsPowerShell").exists() {
207            ps7
208        } else {
209            docs.join("WindowsPowerShell").join("Microsoft.PowerShell_profile.ps1")
210        }
211    }
212    #[cfg(not(windows))]
213    {
214        home.join(".config").join("powershell").join("Microsoft.PowerShell_profile.ps1")
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_generate_completion_script() {
224        let bash_script = generate_completion_script(Shell::Bash);
225        assert!(!bash_script.is_empty());
226        let bash_str = String::from_utf8_lossy(&bash_script);
227        assert!(bash_str.contains("git-notes"));
228        assert!(bash_str.contains("gn"));
229
230        let zsh_script = generate_completion_script(Shell::Zsh);
231        assert!(!zsh_script.is_empty());
232
233        let fish_script = generate_completion_script(Shell::Fish);
234        assert!(!fish_script.is_empty());
235
236        let ps_script = generate_completion_script(Shell::PowerShell);
237        assert!(!ps_script.is_empty());
238
239        let elvish_script = generate_completion_script(Shell::Elvish);
240        assert!(!elvish_script.is_empty());
241    }
242}