cascade_cli/cli/commands/
completions.rs1use 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
9pub 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
18pub 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_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 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
63fn detect_current_and_available_shells() -> Vec<Shell> {
65 let mut shells = Vec::new();
66
67 if let Some(current_shell) = detect_current_shell() {
69 shells.push(current_shell);
70 println!("š Detected current shell: {current_shell:?}");
71 return shells; }
73
74 println!("š Could not detect current shell, checking available shells...");
76 detect_available_shells()
77}
78
79fn 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
92fn detect_available_shells() -> Vec<Shell> {
94 let mut shells = Vec::new();
95
96 if which_shell("bash").is_some() {
98 shells.push(Shell::Bash);
99 }
100
101 if which_shell("zsh").is_some() {
103 shells.push(Shell::Zsh);
104 }
105
106 if which_shell("fish").is_some() {
108 shells.push(Shell::Fish);
109 }
110
111 if shells.is_empty() {
113 shells.push(Shell::Bash);
114 }
115
116 shells
117}
118
119fn 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
135fn install_completion_for_shell(shell: Shell) -> Result<PathBuf> {
137 let completion_dirs = crate::utils::platform::shell_completion_dirs();
139
140 let (completion_dir, filename) = match shell {
141 Shell::Bash => {
142 let bash_dirs: Vec<_> = completion_dirs
144 .iter()
145 .filter(|(name, _)| name.contains("bash"))
146 .collect();
147
148 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 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 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 let zsh_dirs: Vec<_> = completion_dirs
181 .iter()
182 .filter(|(name, _)| name.contains("zsh"))
183 .collect();
184
185 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 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 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 let fish_dirs: Vec<_> = completion_dirs
218 .iter()
219 .filter(|(name, _)| name.contains("fish"))
220 .collect();
221
222 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 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 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 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 crate::utils::path_validation::validate_config_path(&completion_file, &completion_dir)?;
269
270 let mut cmd = Cli::command();
272 let mut content = Vec::new();
273 generate(shell, &mut cmd, "ca", &mut content);
274
275 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 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 if completion_dir.to_string_lossy().contains(
287 &dirs::home_dir()
288 .unwrap_or_default()
289 .to_string_lossy()
290 .to_string(),
291 ) {
292 std::fs::write(&completion_file, &content)?;
294 } else {
295 return Err(e);
297 }
298 }
299 Err(e) => return Err(e),
300 }
301
302 Ok(completion_file)
303}
304
305pub 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
338fn 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
363fn 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 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 std::env::remove_var("SHELL");
438 }
439}