cascade_cli/cli/commands/
completions.rs

1use crate::cli::Cli;
2use crate::errors::{CascadeError, Result};
3use clap::CommandFactory;
4use clap_complete::{generate, Shell};
5use std::fs;
6use std::io;
7use std::path::PathBuf;
8
9/// Generate shell completions for the specified shell
10pub fn generate_completions(shell: Shell) -> Result<()> {
11    let mut cmd = Cli::command();
12    let bin_name = "ca";
13
14    generate(shell, &mut cmd, bin_name, &mut io::stdout());
15    Ok(())
16}
17
18/// Install shell completions to the system
19pub fn install_completions(shell: Option<Shell>) -> Result<()> {
20    let shells_to_install = if let Some(shell) = shell {
21        vec![shell]
22    } else {
23        // Detect current shell first, then fall back to available shells
24        detect_current_and_available_shells()
25    };
26
27    let mut installed = Vec::new();
28    let mut errors = Vec::new();
29
30    for shell in shells_to_install {
31        match install_completion_for_shell(shell) {
32            Ok(path) => {
33                installed.push((shell, path));
34            }
35            Err(e) => {
36                errors.push((shell, e));
37            }
38        }
39    }
40
41    // Report results
42    if !installed.is_empty() {
43        println!("āœ… Shell completions installed:");
44        for (shell, path) in installed {
45            println!("   {:?}: {}", shell, path.display());
46        }
47
48        println!("\nšŸ’” Next steps:");
49        println!("   1. Restart your shell or run: source ~/.bashrc (or equivalent)");
50        println!("   2. Try: ca <TAB><TAB>");
51    }
52
53    if !errors.is_empty() {
54        println!("\nāš ļø  Some installations failed:");
55        for (shell, error) in errors {
56            println!("   {shell:?}: {error}");
57        }
58    }
59
60    Ok(())
61}
62
63/// Detect current shell first, then fall back to available shells
64fn detect_current_and_available_shells() -> Vec<Shell> {
65    let mut shells = Vec::new();
66
67    // First, try to detect the current shell from SHELL environment variable
68    if let Some(current_shell) = detect_current_shell() {
69        shells.push(current_shell);
70        println!("šŸ” Detected current shell: {current_shell:?}");
71        return shells; // Only install for current shell
72    }
73
74    // Fall back to detecting all available shells
75    println!("šŸ” Could not detect current shell, checking available shells...");
76    detect_available_shells()
77}
78
79/// Detect the current shell from the SHELL environment variable
80fn detect_current_shell() -> Option<Shell> {
81    let shell_path = std::env::var("SHELL").ok()?;
82    let shell_name = std::path::Path::new(&shell_path).file_name()?.to_str()?;
83
84    match shell_name {
85        "bash" => Some(Shell::Bash),
86        "zsh" => Some(Shell::Zsh),
87        "fish" => Some(Shell::Fish),
88        _ => None,
89    }
90}
91
92/// Detect which shells are available on the system
93fn detect_available_shells() -> Vec<Shell> {
94    let mut shells = Vec::new();
95
96    // Check for bash
97    if which_shell("bash").is_some() {
98        shells.push(Shell::Bash);
99    }
100
101    // Check for zsh
102    if which_shell("zsh").is_some() {
103        shells.push(Shell::Zsh);
104    }
105
106    // Check for fish
107    if which_shell("fish").is_some() {
108        shells.push(Shell::Fish);
109    }
110
111    // Default to bash if nothing found
112    if shells.is_empty() {
113        shells.push(Shell::Bash);
114    }
115
116    shells
117}
118
119/// Check if a shell exists in PATH
120fn which_shell(shell: &str) -> Option<PathBuf> {
121    std::env::var("PATH")
122        .ok()?
123        .split(crate::utils::platform::path_separator())
124        .map(PathBuf::from)
125        .find_map(|path| {
126            let shell_path = path.join(crate::utils::platform::executable_name(shell));
127            if crate::utils::platform::is_executable(&shell_path) {
128                Some(shell_path)
129            } else {
130                None
131            }
132        })
133}
134
135/// Install completion for a specific shell
136fn install_completion_for_shell(shell: Shell) -> Result<PathBuf> {
137    // Get platform-specific completion directories
138    let completion_dirs = crate::utils::platform::shell_completion_dirs();
139
140    let (completion_dir, filename) = match shell {
141        Shell::Bash => {
142            // Prioritize user directories over system directories
143            let bash_dirs: Vec<_> = completion_dirs
144                .iter()
145                .filter(|(name, _)| name.contains("bash"))
146                .collect();
147
148            // First try user directories
149            let user_dir = bash_dirs
150                .iter()
151                .find(|(name, _)| name.contains("user"))
152                .map(|(_, path)| path.clone())
153                .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
154
155            // If no user directory works, try system directories
156            let system_dir = if user_dir.is_none() {
157                bash_dirs
158                    .iter()
159                    .find(|(name, _)| name.contains("system"))
160                    .map(|(_, path)| path.clone())
161                    .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
162            } else {
163                None
164            };
165
166            let dir = user_dir
167                .or(system_dir)
168                .or_else(|| {
169                    // Fallback to user-specific directory
170                    dirs::home_dir().map(|h| h.join(".bash_completion.d"))
171                })
172                .ok_or_else(|| {
173                    CascadeError::config("Could not find suitable bash completion directory")
174                })?;
175
176            (dir, "ca")
177        }
178        Shell::Zsh => {
179            // Prioritize user directories over system directories
180            let zsh_dirs: Vec<_> = completion_dirs
181                .iter()
182                .filter(|(name, _)| name.contains("zsh"))
183                .collect();
184
185            // First try user directories
186            let user_dir = zsh_dirs
187                .iter()
188                .find(|(name, _)| name.contains("user"))
189                .map(|(_, path)| path.clone())
190                .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
191
192            // If no user directory works, try system directories
193            let system_dir = if user_dir.is_none() {
194                zsh_dirs
195                    .iter()
196                    .find(|(name, _)| name.contains("system"))
197                    .map(|(_, path)| path.clone())
198                    .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
199            } else {
200                None
201            };
202
203            let dir = user_dir
204                .or(system_dir)
205                .or_else(|| {
206                    // Fallback to user-specific directory
207                    dirs::home_dir().map(|h| h.join(".zsh/completions"))
208                })
209                .ok_or_else(|| {
210                    CascadeError::config("Could not find suitable zsh completion directory")
211                })?;
212
213            (dir, "_ca")
214        }
215        Shell::Fish => {
216            // Prioritize user directories over system directories
217            let fish_dirs: Vec<_> = completion_dirs
218                .iter()
219                .filter(|(name, _)| name.contains("fish"))
220                .collect();
221
222            // First try user directories
223            let user_dir = fish_dirs
224                .iter()
225                .find(|(name, _)| name.contains("user"))
226                .map(|(_, path)| path.clone())
227                .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
228
229            // If no user directory works, try system directories
230            let system_dir = if user_dir.is_none() {
231                fish_dirs
232                    .iter()
233                    .find(|(name, _)| name.contains("system"))
234                    .map(|(_, path)| path.clone())
235                    .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
236            } else {
237                None
238            };
239
240            let dir = user_dir
241                .or(system_dir)
242                .or_else(|| {
243                    // Fallback to user-specific directory
244                    dirs::home_dir().map(|h| h.join(".config/fish/completions"))
245                })
246                .ok_or_else(|| {
247                    CascadeError::config("Could not find suitable fish completion directory")
248                })?;
249
250            (dir, "ca.fish")
251        }
252        _ => {
253            return Err(CascadeError::config(format!(
254                "Unsupported shell: {shell:?}"
255            )));
256        }
257    };
258
259    // Create directory if it doesn't exist
260    if !completion_dir.exists() {
261        fs::create_dir_all(&completion_dir)?;
262    }
263
264    let completion_file =
265        completion_dir.join(crate::utils::path_validation::sanitize_filename(filename));
266
267    // Validate the completion file path for security
268    crate::utils::path_validation::validate_config_path(&completion_file, &completion_dir)?;
269
270    // Generate completion content
271    let mut cmd = Cli::command();
272    let mut content = Vec::new();
273    generate(shell, &mut cmd, "ca", &mut content);
274
275    // Add custom completion logic for stack names
276    let custom_completion = generate_custom_completion(shell);
277    if !custom_completion.is_empty() {
278        content.extend_from_slice(custom_completion.as_bytes());
279    }
280
281    // Write to file atomically, with fallback for lock failures
282    match crate::utils::atomic_file::write_bytes(&completion_file, &content) {
283        Ok(()) => {}
284        Err(e) if e.to_string().contains("Timeout waiting for lock") => {
285            // Lock failure - try without locking for user directories
286            if completion_dir.to_string_lossy().contains(
287                &dirs::home_dir()
288                    .unwrap_or_default()
289                    .to_string_lossy()
290                    .to_string(),
291            ) {
292                // This is a user directory, try direct write
293                std::fs::write(&completion_file, &content)?;
294            } else {
295                // System directory, propagate the error
296                return Err(e);
297            }
298        }
299        Err(e) => return Err(e),
300    }
301
302    Ok(completion_file)
303}
304
305/// Show installation status and guidance
306pub fn show_completions_status() -> Result<()> {
307    println!("šŸš€ Shell Completions Status");
308    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
309
310    let available_shells = detect_available_shells();
311
312    println!("\nšŸ“Š Available shells:");
313    for shell in &available_shells {
314        let status = check_completion_installed(*shell);
315        let status_icon = if status { "āœ…" } else { "āŒ" };
316        println!("   {status_icon} {shell:?}");
317    }
318
319    if available_shells
320        .iter()
321        .any(|s| !check_completion_installed(*s))
322    {
323        println!("\nšŸ’” To install completions:");
324        println!("   ca completions install");
325        println!("   ca completions install --shell bash  # for specific shell");
326    } else {
327        println!("\nšŸŽ‰ All available shells have completions installed!");
328    }
329
330    println!("\nšŸ”§ Manual installation:");
331    println!("   ca completions generate bash > ~/.bash_completion.d/ca");
332    println!("   ca completions generate zsh > ~/.zsh/completions/_ca");
333    println!("   ca completions generate fish > ~/.config/fish/completions/ca.fish");
334
335    Ok(())
336}
337
338/// Check if completion is installed for a shell
339fn check_completion_installed(shell: Shell) -> bool {
340    let home_dir = match dirs::home_dir() {
341        Some(dir) => dir,
342        None => return false,
343    };
344
345    let possible_paths = match shell {
346        Shell::Bash => vec![
347            home_dir.join(".bash_completion.d/ca"),
348            PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
349            PathBuf::from("/etc/bash_completion.d/ca"),
350        ],
351        Shell::Zsh => vec![
352            home_dir.join(".oh-my-zsh/completions/_ca"),
353            home_dir.join(".zsh/completions/_ca"),
354            PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
355        ],
356        Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
357        _ => return false,
358    };
359
360    possible_paths.iter().any(|path| path.exists())
361}
362
363/// Generate custom completion logic for dynamic values
364fn generate_custom_completion(shell: Shell) -> String {
365    match shell {
366        Shell::Bash => {
367            r#"
368# Custom completion for ca switch command
369_ca_switch_completion() {
370    local cur="${COMP_WORDS[COMP_CWORD]}"
371    local stacks=$(ca completion-helper stack-names 2>/dev/null)
372    COMPREPLY=($(compgen -W "$stacks" -- "$cur"))
373}
374
375# Replace the default completion for 'ca switch' with our custom function
376complete -F _ca_switch_completion ca
377"#.to_string()
378        }
379        Shell::Zsh => {
380            r#"
381# Custom completion for ca switch command
382_ca_switch_completion() {
383    local stacks=($(ca completion-helper stack-names 2>/dev/null))
384    _describe 'stacks' stacks
385}
386
387# Override the switch completion
388compdef _ca_switch_completion ca switch
389"#.to_string()
390        }
391        Shell::Fish => {
392            r#"
393# Custom completion for ca switch command
394complete -c ca -f -n '__fish_seen_subcommand_from switch' -a '(ca completion-helper stack-names 2>/dev/null)'
395"#.to_string()
396        }
397        _ => String::new(),
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_detect_shells() {
407        let shells = detect_available_shells();
408        assert!(!shells.is_empty());
409    }
410
411    #[test]
412    fn test_generate_bash_completion() {
413        let result = generate_completions(Shell::Bash);
414        assert!(result.is_ok());
415    }
416
417    #[test]
418    fn test_detect_current_shell() {
419        // Test with a mocked SHELL environment variable
420        std::env::set_var("SHELL", "/bin/zsh");
421        let shell = detect_current_shell();
422        assert_eq!(shell, Some(Shell::Zsh));
423
424        std::env::set_var("SHELL", "/usr/bin/bash");
425        let shell = detect_current_shell();
426        assert_eq!(shell, Some(Shell::Bash));
427
428        std::env::set_var("SHELL", "/usr/local/bin/fish");
429        let shell = detect_current_shell();
430        assert_eq!(shell, Some(Shell::Fish));
431
432        std::env::set_var("SHELL", "/bin/unknown");
433        let shell = detect_current_shell();
434        assert_eq!(shell, None);
435
436        // Clean up
437        std::env::remove_var("SHELL");
438    }
439}