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            // Find the first suitable bash completion directory
143            let bash_dirs: Vec<_> = completion_dirs
144                .iter()
145                .filter(|(name, _)| name.contains("bash"))
146                .map(|(_, path)| path.clone())
147                .collect();
148
149            let dir = bash_dirs
150                .into_iter()
151                .find(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
152                .or_else(|| {
153                    // Fallback to user-specific directory
154                    dirs::home_dir().map(|h| h.join(".bash_completion.d"))
155                })
156                .ok_or_else(|| {
157                    CascadeError::config("Could not find suitable bash completion directory")
158                })?;
159
160            (dir, "ca")
161        }
162        Shell::Zsh => {
163            // Find the first suitable zsh completion directory
164            let zsh_dirs: Vec<_> = completion_dirs
165                .iter()
166                .filter(|(name, _)| name.contains("zsh"))
167                .map(|(_, path)| path.clone())
168                .collect();
169
170            let dir = zsh_dirs
171                .into_iter()
172                .find(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
173                .or_else(|| {
174                    // Fallback to user-specific directory
175                    dirs::home_dir().map(|h| h.join(".zsh/completions"))
176                })
177                .ok_or_else(|| {
178                    CascadeError::config("Could not find suitable zsh completion directory")
179                })?;
180
181            (dir, "_ca")
182        }
183        Shell::Fish => {
184            // Find the first suitable fish completion directory
185            let fish_dirs: Vec<_> = completion_dirs
186                .iter()
187                .filter(|(name, _)| name.contains("fish"))
188                .map(|(_, path)| path.clone())
189                .collect();
190
191            let dir = fish_dirs
192                .into_iter()
193                .find(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
194                .or_else(|| {
195                    // Fallback to user-specific directory
196                    dirs::home_dir().map(|h| h.join(".config/fish/completions"))
197                })
198                .ok_or_else(|| {
199                    CascadeError::config("Could not find suitable fish completion directory")
200                })?;
201
202            (dir, "ca.fish")
203        }
204        _ => {
205            return Err(CascadeError::config(format!(
206                "Unsupported shell: {shell:?}"
207            )));
208        }
209    };
210
211    // Create directory if it doesn't exist
212    if !completion_dir.exists() {
213        fs::create_dir_all(&completion_dir)?;
214    }
215
216    let completion_file =
217        completion_dir.join(crate::utils::path_validation::sanitize_filename(filename));
218
219    // Validate the completion file path for security
220    crate::utils::path_validation::validate_config_path(&completion_file, &completion_dir)?;
221
222    // Generate completion content
223    let mut cmd = Cli::command();
224    let mut content = Vec::new();
225    generate(shell, &mut cmd, "ca", &mut content);
226
227    // Write to file atomically
228    crate::utils::atomic_file::write_bytes(&completion_file, &content)?;
229
230    Ok(completion_file)
231}
232
233/// Show installation status and guidance
234pub fn show_completions_status() -> Result<()> {
235    println!("šŸš€ Shell Completions Status");
236    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
237
238    let available_shells = detect_available_shells();
239
240    println!("\nšŸ“Š Available shells:");
241    for shell in &available_shells {
242        let status = check_completion_installed(*shell);
243        let status_icon = if status { "āœ…" } else { "āŒ" };
244        println!("   {status_icon} {shell:?}");
245    }
246
247    if available_shells
248        .iter()
249        .any(|s| !check_completion_installed(*s))
250    {
251        println!("\nšŸ’” To install completions:");
252        println!("   ca completions install");
253        println!("   ca completions install --shell bash  # for specific shell");
254    } else {
255        println!("\nšŸŽ‰ All available shells have completions installed!");
256    }
257
258    println!("\nšŸ”§ Manual installation:");
259    println!("   ca completions generate bash > ~/.bash_completion.d/ca");
260    println!("   ca completions generate zsh > ~/.zsh/completions/_ca");
261    println!("   ca completions generate fish > ~/.config/fish/completions/ca.fish");
262
263    Ok(())
264}
265
266/// Check if completion is installed for a shell
267fn check_completion_installed(shell: Shell) -> bool {
268    let home_dir = match dirs::home_dir() {
269        Some(dir) => dir,
270        None => return false,
271    };
272
273    let possible_paths = match shell {
274        Shell::Bash => vec![
275            home_dir.join(".bash_completion.d/ca"),
276            PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
277            PathBuf::from("/etc/bash_completion.d/ca"),
278        ],
279        Shell::Zsh => vec![
280            home_dir.join(".oh-my-zsh/completions/_ca"),
281            home_dir.join(".zsh/completions/_ca"),
282            PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
283        ],
284        Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
285        _ => return false,
286    };
287
288    possible_paths.iter().any(|path| path.exists())
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_detect_shells() {
297        let shells = detect_available_shells();
298        assert!(!shells.is_empty());
299    }
300
301    #[test]
302    fn test_generate_bash_completion() {
303        let result = generate_completions(Shell::Bash);
304        assert!(result.is_ok());
305    }
306
307    #[test]
308    fn test_detect_current_shell() {
309        // Test with a mocked SHELL environment variable
310        std::env::set_var("SHELL", "/bin/zsh");
311        let shell = detect_current_shell();
312        assert_eq!(shell, Some(Shell::Zsh));
313
314        std::env::set_var("SHELL", "/usr/bin/bash");
315        let shell = detect_current_shell();
316        assert_eq!(shell, Some(Shell::Bash));
317
318        std::env::set_var("SHELL", "/usr/local/bin/fish");
319        let shell = detect_current_shell();
320        assert_eq!(shell, Some(Shell::Fish));
321
322        std::env::set_var("SHELL", "/bin/unknown");
323        let shell = detect_current_shell();
324        assert_eq!(shell, None);
325
326        // Clean up
327        std::env::remove_var("SHELL");
328    }
329}