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        println!();
363        Output::warning("⚠️  Important: Just having the file installed doesn't mean it works!");
364        Output::tip("For zsh users, you must also:");
365        Output::bullet("Add 'fpath=(~/.zsh/completions $fpath)' to ~/.zshrc");
366        Output::bullet("Add 'autoload -Uz compinit && compinit' to ~/.zshrc");
367        Output::bullet("Then run 'source ~/.zshrc'");
368    }
369
370    println!();
371    Output::section("Manual installation");
372    Output::command_example("ca completions generate bash > ~/.bash_completion.d/ca");
373    Output::command_example("ca completions generate zsh > ~/.zsh/completions/_ca");
374    Output::command_example("ca completions generate fish > ~/.config/fish/completions/ca.fish");
375
376    Ok(())
377}
378
379/// Check if completion is installed for a shell
380fn check_completion_installed(shell: Shell) -> bool {
381    let home_dir = match dirs::home_dir() {
382        Some(dir) => dir,
383        None => return false,
384    };
385
386    let possible_paths = match shell {
387        Shell::Bash => vec![
388            home_dir.join(".bash_completion.d/ca"),
389            PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
390            PathBuf::from("/etc/bash_completion.d/ca"),
391        ],
392        Shell::Zsh => vec![
393            home_dir.join(".oh-my-zsh/completions/_ca"),
394            home_dir.join(".zsh/completions/_ca"),
395            PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
396        ],
397        Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
398        _ => return false,
399    };
400
401    possible_paths.iter().any(|path| path.exists())
402}
403
404/// Generate custom completion logic for dynamic values
405fn generate_custom_completion(shell: Shell) -> String {
406    match shell {
407        Shell::Bash => {
408            r#"
409# Custom completion for ca switch command
410_ca_switch_completion() {
411    local cur="${COMP_WORDS[COMP_CWORD]}"
412    local stacks=$(ca completion-helper stack-names 2>/dev/null)
413    COMPREPLY=($(compgen -W "$stacks" -- "$cur"))
414}
415
416# Replace the default completion for 'ca switch' with our custom function
417complete -F _ca_switch_completion ca
418"#.to_string()
419        }
420        Shell::Zsh => {
421            r#"
422# Custom completion for ca switch command
423_ca_switch_completion() {
424    local stacks=($(ca completion-helper stack-names 2>/dev/null))
425    _describe 'stacks' stacks
426}
427
428# Override the switch completion
429compdef _ca_switch_completion ca switch
430"#.to_string()
431        }
432        Shell::Fish => {
433            r#"
434# Custom completion for ca switch command
435complete -c ca -f -n '__fish_seen_subcommand_from switch' -a '(ca completion-helper stack-names 2>/dev/null)'
436"#.to_string()
437        }
438        _ => String::new(),
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_detect_shells() {
448        let shells = detect_available_shells();
449        assert!(!shells.is_empty());
450    }
451
452    #[test]
453    fn test_generate_bash_completion() {
454        let result = generate_completions(Shell::Bash);
455        assert!(result.is_ok());
456    }
457
458    #[test]
459    fn test_detect_current_shell() {
460        // Test with a mocked SHELL environment variable
461        std::env::set_var("SHELL", "/bin/zsh");
462        let shell = detect_current_shell();
463        assert_eq!(shell, Some(Shell::Zsh));
464
465        std::env::set_var("SHELL", "/usr/bin/bash");
466        let shell = detect_current_shell();
467        assert_eq!(shell, Some(Shell::Bash));
468
469        std::env::set_var("SHELL", "/usr/local/bin/fish");
470        let shell = detect_current_shell();
471        assert_eq!(shell, Some(Shell::Fish));
472
473        std::env::set_var("SHELL", "/bin/unknown");
474        let shell = detect_current_shell();
475        assert_eq!(shell, None);
476
477        // Clean up
478        std::env::remove_var("SHELL");
479    }
480}