cascade_cli/cli/commands/
completions.rs

1use crate::cli::output::Output;
2use crate::cli::Cli;
3use crate::errors::{CascadeError, Result};
4use clap::CommandFactory;
5use clap_complete::{generate, Shell};
6use std::fs;
7use std::io;
8use std::path::PathBuf;
9
10/// Generate shell completions for the specified shell
11pub fn generate_completions(shell: Shell) -> Result<()> {
12    let mut cmd = Cli::command();
13    let bin_name = "ca";
14
15    generate(shell, &mut cmd, bin_name, &mut io::stdout());
16    Ok(())
17}
18
19/// Install shell completions to the system
20pub fn install_completions(shell: Option<Shell>) -> Result<()> {
21    let shells_to_install = if let Some(shell) = shell {
22        vec![shell]
23    } else {
24        // Detect current shell first, then fall back to available shells
25        detect_current_and_available_shells()
26    };
27
28    let mut installed = Vec::new();
29    let mut errors = Vec::new();
30
31    for shell in shells_to_install {
32        match install_completion_for_shell(shell) {
33            Ok(path) => {
34                installed.push((shell, path));
35            }
36            Err(e) => {
37                errors.push((shell, e));
38            }
39        }
40    }
41
42    // Report results
43    if !installed.is_empty() {
44        Output::success("Shell completions installed:");
45        for (shell, path) in &installed {
46            Output::sub_item(format!("{:?}: {}", shell, path.display()));
47        }
48
49        println!();
50        Output::tip("Next steps:");
51
52        // Provide shell-specific setup instructions
53        for (shell, path) in &installed {
54            match shell {
55                Shell::Zsh => {
56                    if path.to_string_lossy().contains(".zsh/completions") {
57                        println!();
58                        Output::warning("⚠️  Zsh requires additional setup:");
59                        Output::bullet("Add this to your ~/.zshrc:");
60                        println!("      fpath=(~/.zsh/completions $fpath)");
61                        println!("      autoload -Uz compinit && compinit");
62                        Output::bullet("Then reload: source ~/.zshrc");
63                    }
64                }
65                Shell::Bash => {
66                    if path.to_string_lossy().contains(".bash_completion.d") {
67                        println!();
68                        Output::info("For bash completions to work:");
69                        Output::bullet("Ensure bash-completion is installed");
70                        Output::bullet("Then reload: source ~/.bashrc");
71                    }
72                }
73                _ => {}
74            }
75        }
76
77        println!();
78        Output::bullet("Try: ca <TAB><TAB>");
79    }
80
81    if !errors.is_empty() {
82        println!();
83        Output::warning("Some installations failed:");
84        for (shell, error) in errors {
85            Output::sub_item(format!("{shell:?}: {error}"));
86        }
87    }
88
89    Ok(())
90}
91
92/// Detect current shell first, then fall back to available shells
93fn detect_current_and_available_shells() -> Vec<Shell> {
94    let mut shells = Vec::new();
95
96    // First, try to detect the current shell from SHELL environment variable
97    if let Some(current_shell) = detect_current_shell() {
98        shells.push(current_shell);
99        Output::info(format!("Detected current shell: {current_shell:?}"));
100        return shells; // Only install for current shell
101    }
102
103    // Fall back to detecting all available shells
104    Output::info("Could not detect current shell, checking available shells...");
105    detect_available_shells()
106}
107
108/// Detect the current shell from the SHELL environment variable
109fn detect_current_shell() -> Option<Shell> {
110    let shell_path = std::env::var("SHELL").ok()?;
111    let shell_name = std::path::Path::new(&shell_path).file_name()?.to_str()?;
112
113    match shell_name {
114        "bash" => Some(Shell::Bash),
115        "zsh" => Some(Shell::Zsh),
116        "fish" => Some(Shell::Fish),
117        _ => None,
118    }
119}
120
121/// Detect which shells are available on the system
122fn detect_available_shells() -> Vec<Shell> {
123    let mut shells = Vec::new();
124
125    // Check for bash
126    if which_shell("bash").is_some() {
127        shells.push(Shell::Bash);
128    }
129
130    // Check for zsh
131    if which_shell("zsh").is_some() {
132        shells.push(Shell::Zsh);
133    }
134
135    // Check for fish
136    if which_shell("fish").is_some() {
137        shells.push(Shell::Fish);
138    }
139
140    // Default to bash if nothing found
141    if shells.is_empty() {
142        shells.push(Shell::Bash);
143    }
144
145    shells
146}
147
148/// Check if a shell exists in PATH
149fn which_shell(shell: &str) -> Option<PathBuf> {
150    std::env::var("PATH")
151        .ok()?
152        .split(crate::utils::platform::path_separator())
153        .map(PathBuf::from)
154        .find_map(|path| {
155            let shell_path = path.join(crate::utils::platform::executable_name(shell));
156            if crate::utils::platform::is_executable(&shell_path) {
157                Some(shell_path)
158            } else {
159                None
160            }
161        })
162}
163
164/// Install completion for a specific shell
165fn install_completion_for_shell(shell: Shell) -> Result<PathBuf> {
166    // Get platform-specific completion directories
167    let completion_dirs = crate::utils::platform::shell_completion_dirs();
168
169    let (completion_dir, filename) = match shell {
170        Shell::Bash => {
171            // Prioritize user directories over system directories
172            let bash_dirs: Vec<_> = completion_dirs
173                .iter()
174                .filter(|(name, _)| name.contains("bash"))
175                .collect();
176
177            // First try user directories
178            let user_dir = bash_dirs
179                .iter()
180                .find(|(name, _)| name.contains("user"))
181                .map(|(_, path)| path.clone())
182                .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
183
184            // If no user directory works, try system directories
185            let system_dir = if user_dir.is_none() {
186                bash_dirs
187                    .iter()
188                    .find(|(name, _)| name.contains("system"))
189                    .map(|(_, path)| path.clone())
190                    .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
191            } else {
192                None
193            };
194
195            let dir = user_dir
196                .or(system_dir)
197                .or_else(|| {
198                    // Fallback to user-specific directory
199                    dirs::home_dir().map(|h| h.join(".bash_completion.d"))
200                })
201                .ok_or_else(|| {
202                    CascadeError::config("Could not find suitable bash completion directory")
203                })?;
204
205            (dir, "ca")
206        }
207        Shell::Zsh => {
208            // Prioritize user directories over system directories
209            let zsh_dirs: Vec<_> = completion_dirs
210                .iter()
211                .filter(|(name, _)| name.contains("zsh"))
212                .collect();
213
214            // First try user directories
215            let user_dir = zsh_dirs
216                .iter()
217                .find(|(name, _)| name.contains("user"))
218                .map(|(_, path)| path.clone())
219                .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
220
221            // If no user directory works, try system directories
222            let system_dir = if user_dir.is_none() {
223                zsh_dirs
224                    .iter()
225                    .find(|(name, _)| name.contains("system"))
226                    .map(|(_, path)| path.clone())
227                    .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
228            } else {
229                None
230            };
231
232            let dir = user_dir
233                .or(system_dir)
234                .or_else(|| {
235                    // Fallback to user-specific directory
236                    dirs::home_dir().map(|h| h.join(".zsh/completions"))
237                })
238                .ok_or_else(|| {
239                    CascadeError::config("Could not find suitable zsh completion directory")
240                })?;
241
242            (dir, "_ca")
243        }
244        Shell::Fish => {
245            // Prioritize user directories over system directories
246            let fish_dirs: Vec<_> = completion_dirs
247                .iter()
248                .filter(|(name, _)| name.contains("fish"))
249                .collect();
250
251            // First try user directories
252            let user_dir = fish_dirs
253                .iter()
254                .find(|(name, _)| name.contains("user"))
255                .map(|(_, path)| path.clone())
256                .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
257
258            // If no user directory works, try system directories
259            let system_dir = if user_dir.is_none() {
260                fish_dirs
261                    .iter()
262                    .find(|(name, _)| name.contains("system"))
263                    .map(|(_, path)| path.clone())
264                    .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
265            } else {
266                None
267            };
268
269            let dir = user_dir
270                .or(system_dir)
271                .or_else(|| {
272                    // Fallback to user-specific directory
273                    dirs::home_dir().map(|h| h.join(".config/fish/completions"))
274                })
275                .ok_or_else(|| {
276                    CascadeError::config("Could not find suitable fish completion directory")
277                })?;
278
279            (dir, "ca.fish")
280        }
281        _ => {
282            return Err(CascadeError::config(format!(
283                "Unsupported shell: {shell:?}"
284            )));
285        }
286    };
287
288    // Create directory if it doesn't exist
289    if !completion_dir.exists() {
290        fs::create_dir_all(&completion_dir)?;
291    }
292
293    let completion_file =
294        completion_dir.join(crate::utils::path_validation::sanitize_filename(filename));
295
296    // Validate the completion file path for security
297    crate::utils::path_validation::validate_config_path(&completion_file, &completion_dir)?;
298
299    // Generate completion content
300    let mut cmd = Cli::command();
301    let mut content = Vec::new();
302    generate(shell, &mut cmd, "ca", &mut content);
303
304    // Add custom completion logic for stack names
305    let custom_completion = generate_custom_completion(shell);
306    if !custom_completion.is_empty() {
307        content.extend_from_slice(custom_completion.as_bytes());
308    }
309
310    // Write to file atomically, with fallback for lock failures
311    match crate::utils::atomic_file::write_bytes(&completion_file, &content) {
312        Ok(()) => {}
313        Err(e) if e.to_string().contains("Timeout waiting for lock") => {
314            // Lock failure - try without locking for user directories
315            if completion_dir.to_string_lossy().contains(
316                &dirs::home_dir()
317                    .unwrap_or_default()
318                    .to_string_lossy()
319                    .to_string(),
320            ) {
321                // This is a user directory, try direct write
322                std::fs::write(&completion_file, &content)?;
323            } else {
324                // System directory, propagate the error
325                return Err(e);
326            }
327        }
328        Err(e) => return Err(e),
329    }
330
331    Ok(completion_file)
332}
333
334/// Show installation status and guidance
335pub fn show_completions_status() -> Result<()> {
336    Output::section("Shell Completions Status");
337
338    let available_shells = detect_available_shells();
339
340    Output::section("Available shells");
341    for shell in &available_shells {
342        let status = check_completion_installed(*shell);
343        if status {
344            Output::success(format!("{shell:?}"));
345        } else {
346            Output::error(format!("{shell:?}"));
347        }
348    }
349
350    let all_installed = available_shells
351        .iter()
352        .all(|s| check_completion_installed(*s));
353
354    if !all_installed {
355        println!();
356        Output::tip("To install completions:");
357        Output::command_example("ca completions install");
358        Output::command_example("ca completions install --shell bash  # for specific shell");
359    } else {
360        println!();
361        Output::success("All available shells have completions installed!");
362
363        // Check if zsh is available and provide setup instructions
364        if available_shells.contains(&Shell::Zsh) {
365            println!();
366
367            // Check if zsh is already configured
368            let zshrc_path = dirs::home_dir()
369                .map(|h| h.join(".zshrc"))
370                .unwrap_or_else(|| PathBuf::from("~/.zshrc"));
371
372            let mut needs_fpath = true;
373            let mut needs_compinit = true;
374
375            let mut using_omz = false;
376            let mut omz_line = None;
377
378            if let Ok(zshrc_content) = std::fs::read_to_string(&zshrc_path) {
379                // Check if Oh-My-Zsh is being used
380                if zshrc_content.contains("oh-my-zsh.sh") {
381                    using_omz = true;
382                    // Find the line number where Oh-My-Zsh is sourced
383                    for (i, line) in zshrc_content.lines().enumerate() {
384                        if line.contains("source") && line.contains("oh-my-zsh.sh") {
385                            omz_line = Some(i + 1);
386                            break;
387                        }
388                    }
389                }
390
391                if zshrc_content.contains("fpath=(~/.zsh/completions")
392                    || zshrc_content.contains("fpath=(\"$HOME/.zsh/completions\"")
393                    || zshrc_content.contains("fpath=($HOME/.zsh/completions")
394                {
395                    needs_fpath = false;
396                }
397                if zshrc_content.contains("compinit") {
398                    needs_compinit = false;
399                }
400            }
401
402            if needs_fpath || needs_compinit {
403                Output::warning("Zsh requires additional setup for completions to work");
404                println!();
405
406                if using_omz {
407                    Output::sub_item("Detected Oh-My-Zsh - special setup required:");
408                    println!();
409                    if let Some(line_num) = omz_line {
410                        Output::info(format!("Oh-My-Zsh loads at line {} in ~/.zshrc", line_num));
411                        Output::sub_item("The fpath MUST be set BEFORE Oh-My-Zsh loads");
412                        Output::sub_item(
413                            "Oh-My-Zsh calls compinit internally, so DON'T add compinit yourself",
414                        );
415                        println!();
416                    }
417
418                    Output::sub_item("Option 1: Manual edit (recommended)");
419                    Output::bullet("Open ~/.zshrc in an editor");
420                    Output::bullet("Find the line: source $ZSH/oh-my-zsh.sh");
421                    Output::bullet("Add this line BEFORE it:");
422                    println!("      fpath=(~/.zsh/completions $fpath)");
423                    Output::bullet("Make sure there's NO 'compinit' line at the end of ~/.zshrc");
424                    Output::bullet("Save, then clear Oh-My-Zsh cache and reload:");
425                    println!("      rm -f ~/.zcompdump && exec zsh");
426                    println!();
427
428                    Output::sub_item("Option 2: Automatic (requires sed)");
429                    if let Some(line_num) = omz_line {
430                        let insert_line = line_num;
431                        Output::command_example(format!(
432                            "sed -i.bak '{}i\\fpath=(~/.zsh/completions $fpath)' ~/.zshrc",
433                            insert_line
434                        ));
435                        Output::command_example("rm -f ~/.zcompdump && exec zsh");
436                    }
437                } else {
438                    Output::sub_item("Run these commands to complete setup:");
439                    println!();
440
441                    if needs_fpath {
442                        Output::command_example(
443                            r#"echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc"#,
444                        );
445                    }
446                    if needs_compinit {
447                        Output::command_example(
448                            r#"echo 'autoload -Uz compinit && compinit' >> ~/.zshrc"#,
449                        );
450                    }
451                    Output::command_example("source ~/.zshrc");
452                }
453            } else {
454                Output::success("Zsh is properly configured for completions!");
455
456                if using_omz {
457                    println!();
458                    Output::tip("If completions aren't working, clear Oh-My-Zsh cache:");
459                    Output::command_example("rm -f ~/.zcompdump && exec zsh");
460                }
461            }
462        }
463    }
464
465    println!();
466    Output::section("Manual installation");
467    Output::command_example("ca completions generate bash > ~/.bash_completion.d/ca");
468    Output::command_example("ca completions generate zsh > ~/.zsh/completions/_ca");
469    Output::command_example("ca completions generate fish > ~/.config/fish/completions/ca.fish");
470
471    Ok(())
472}
473
474/// Check if completion is installed for a shell
475fn check_completion_installed(shell: Shell) -> bool {
476    let home_dir = match dirs::home_dir() {
477        Some(dir) => dir,
478        None => return false,
479    };
480
481    let possible_paths = match shell {
482        Shell::Bash => vec![
483            home_dir.join(".bash_completion.d/ca"),
484            PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
485            PathBuf::from("/etc/bash_completion.d/ca"),
486        ],
487        Shell::Zsh => vec![
488            home_dir.join(".oh-my-zsh/completions/_ca"),
489            home_dir.join(".zsh/completions/_ca"),
490            PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
491        ],
492        Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
493        _ => return false,
494    };
495
496    possible_paths.iter().any(|path| path.exists())
497}
498
499/// Generate custom completion logic for dynamic values
500fn generate_custom_completion(shell: Shell) -> String {
501    match shell {
502        Shell::Bash => {
503            r#"
504# Custom completion for ca switch command
505_ca_switch_completion() {
506    local cur="${COMP_WORDS[COMP_CWORD]}"
507    local stacks=$(ca completion-helper stack-names 2>/dev/null)
508    COMPREPLY=($(compgen -W "$stacks" -- "$cur"))
509}
510
511# Replace the default completion for 'ca switch' with our custom function
512complete -F _ca_switch_completion ca
513"#.to_string()
514        }
515        Shell::Zsh => {
516            r#"
517# Custom completion for ca switch command
518_ca_switch_completion() {
519    local stacks=($(ca completion-helper stack-names 2>/dev/null))
520    _describe 'stacks' stacks
521}
522
523# Override the switch completion
524compdef _ca_switch_completion ca switch
525
526# Explicitly bind the main completion function to 'ca'
527# This ensures the completion works even if Oh-My-Zsh or other plugins interfere
528compdef _ca ca
529"#.to_string()
530        }
531        Shell::Fish => {
532            r#"
533# Custom completion for ca switch command
534complete -c ca -f -n '__fish_seen_subcommand_from switch' -a '(ca completion-helper stack-names 2>/dev/null)'
535"#.to_string()
536        }
537        _ => String::new(),
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_detect_shells() {
547        let shells = detect_available_shells();
548        assert!(!shells.is_empty());
549    }
550
551    #[test]
552    fn test_generate_bash_completion() {
553        let result = generate_completions(Shell::Bash);
554        assert!(result.is_ok());
555    }
556
557    #[test]
558    fn test_detect_current_shell() {
559        // Test with a mocked SHELL environment variable
560        std::env::set_var("SHELL", "/bin/zsh");
561        let shell = detect_current_shell();
562        assert_eq!(shell, Some(Shell::Zsh));
563
564        std::env::set_var("SHELL", "/usr/bin/bash");
565        let shell = detect_current_shell();
566        assert_eq!(shell, Some(Shell::Bash));
567
568        std::env::set_var("SHELL", "/usr/local/bin/fish");
569        let shell = detect_current_shell();
570        assert_eq!(shell, Some(Shell::Fish));
571
572        std::env::set_var("SHELL", "/bin/unknown");
573        let shell = detect_current_shell();
574        assert_eq!(shell, None);
575
576        // Clean up
577        std::env::remove_var("SHELL");
578    }
579}