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    // Write to file atomically, with fallback for lock failures
276    match crate::utils::atomic_file::write_bytes(&completion_file, &content) {
277        Ok(()) => {}
278        Err(e) if e.to_string().contains("Timeout waiting for lock") => {
279            // Lock failure - try without locking for user directories
280            if completion_dir.to_string_lossy().contains(
281                &dirs::home_dir()
282                    .unwrap_or_default()
283                    .to_string_lossy()
284                    .to_string(),
285            ) {
286                // This is a user directory, try direct write
287                std::fs::write(&completion_file, &content)?;
288            } else {
289                // System directory, propagate the error
290                return Err(e);
291            }
292        }
293        Err(e) => return Err(e),
294    }
295
296    Ok(completion_file)
297}
298
299/// Show installation status and guidance
300pub fn show_completions_status() -> Result<()> {
301    println!("šŸš€ Shell Completions Status");
302    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
303
304    let available_shells = detect_available_shells();
305
306    println!("\nšŸ“Š Available shells:");
307    for shell in &available_shells {
308        let status = check_completion_installed(*shell);
309        let status_icon = if status { "āœ…" } else { "āŒ" };
310        println!("   {status_icon} {shell:?}");
311    }
312
313    if available_shells
314        .iter()
315        .any(|s| !check_completion_installed(*s))
316    {
317        println!("\nšŸ’” To install completions:");
318        println!("   ca completions install");
319        println!("   ca completions install --shell bash  # for specific shell");
320    } else {
321        println!("\nšŸŽ‰ All available shells have completions installed!");
322    }
323
324    println!("\nšŸ”§ Manual installation:");
325    println!("   ca completions generate bash > ~/.bash_completion.d/ca");
326    println!("   ca completions generate zsh > ~/.zsh/completions/_ca");
327    println!("   ca completions generate fish > ~/.config/fish/completions/ca.fish");
328
329    Ok(())
330}
331
332/// Check if completion is installed for a shell
333fn check_completion_installed(shell: Shell) -> bool {
334    let home_dir = match dirs::home_dir() {
335        Some(dir) => dir,
336        None => return false,
337    };
338
339    let possible_paths = match shell {
340        Shell::Bash => vec![
341            home_dir.join(".bash_completion.d/ca"),
342            PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
343            PathBuf::from("/etc/bash_completion.d/ca"),
344        ],
345        Shell::Zsh => vec![
346            home_dir.join(".oh-my-zsh/completions/_ca"),
347            home_dir.join(".zsh/completions/_ca"),
348            PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
349        ],
350        Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
351        _ => return false,
352    };
353
354    possible_paths.iter().any(|path| path.exists())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_detect_shells() {
363        let shells = detect_available_shells();
364        assert!(!shells.is_empty());
365    }
366
367    #[test]
368    fn test_generate_bash_completion() {
369        let result = generate_completions(Shell::Bash);
370        assert!(result.is_ok());
371    }
372
373    #[test]
374    fn test_detect_current_shell() {
375        // Test with a mocked SHELL environment variable
376        std::env::set_var("SHELL", "/bin/zsh");
377        let shell = detect_current_shell();
378        assert_eq!(shell, Some(Shell::Zsh));
379
380        std::env::set_var("SHELL", "/usr/bin/bash");
381        let shell = detect_current_shell();
382        assert_eq!(shell, Some(Shell::Bash));
383
384        std::env::set_var("SHELL", "/usr/local/bin/fish");
385        let shell = detect_current_shell();
386        assert_eq!(shell, Some(Shell::Fish));
387
388        std::env::set_var("SHELL", "/bin/unknown");
389        let shell = detect_current_shell();
390        assert_eq!(shell, None);
391
392        // Clean up
393        std::env::remove_var("SHELL");
394    }
395}