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