cascade_cli/cli/commands/
completions.rs1use 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
10pub 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
19pub 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_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 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
52 for (shell, path) in &installed {
54 match shell {
55 Shell::Zsh => {
56 if path.to_string_lossy().contains(".zsh/completions") {
57 println!();
58 Output::warning("⚠️ Zsh requires additional setup:");
59 Output::bullet("Add this to your ~/.zshrc:");
60 println!(" fpath=(~/.zsh/completions $fpath)");
61 println!(" autoload -Uz compinit && compinit");
62 Output::bullet("Then reload: source ~/.zshrc");
63 }
64 }
65 Shell::Bash => {
66 if path.to_string_lossy().contains(".bash_completion.d") {
67 println!();
68 Output::info("For bash completions to work:");
69 Output::bullet("Ensure bash-completion is installed");
70 Output::bullet("Then reload: source ~/.bashrc");
71 }
72 }
73 _ => {}
74 }
75 }
76
77 println!();
78 Output::bullet("Try: ca <TAB><TAB>");
79 }
80
81 if !errors.is_empty() {
82 println!();
83 Output::warning("Some installations failed:");
84 for (shell, error) in errors {
85 Output::sub_item(format!("{shell:?}: {error}"));
86 }
87 }
88
89 Ok(())
90}
91
92fn detect_current_and_available_shells() -> Vec<Shell> {
94 let mut shells = Vec::new();
95
96 if let Some(current_shell) = detect_current_shell() {
98 shells.push(current_shell);
99 Output::info(format!("Detected current shell: {current_shell:?}"));
100 return shells; }
102
103 Output::info("Could not detect current shell, checking available shells...");
105 detect_available_shells()
106}
107
108fn detect_current_shell() -> Option<Shell> {
110 let shell_path = std::env::var("SHELL").ok()?;
111 let shell_name = std::path::Path::new(&shell_path).file_name()?.to_str()?;
112
113 match shell_name {
114 "bash" => Some(Shell::Bash),
115 "zsh" => Some(Shell::Zsh),
116 "fish" => Some(Shell::Fish),
117 _ => None,
118 }
119}
120
121fn detect_available_shells() -> Vec<Shell> {
123 let mut shells = Vec::new();
124
125 if which_shell("bash").is_some() {
127 shells.push(Shell::Bash);
128 }
129
130 if which_shell("zsh").is_some() {
132 shells.push(Shell::Zsh);
133 }
134
135 if which_shell("fish").is_some() {
137 shells.push(Shell::Fish);
138 }
139
140 if shells.is_empty() {
142 shells.push(Shell::Bash);
143 }
144
145 shells
146}
147
148fn which_shell(shell: &str) -> Option<PathBuf> {
150 std::env::var("PATH")
151 .ok()?
152 .split(crate::utils::platform::path_separator())
153 .map(PathBuf::from)
154 .find_map(|path| {
155 let shell_path = path.join(crate::utils::platform::executable_name(shell));
156 if crate::utils::platform::is_executable(&shell_path) {
157 Some(shell_path)
158 } else {
159 None
160 }
161 })
162}
163
164fn install_completion_for_shell(shell: Shell) -> Result<PathBuf> {
166 let completion_dirs = crate::utils::platform::shell_completion_dirs();
168
169 let (completion_dir, filename) = match shell {
170 Shell::Bash => {
171 let bash_dirs: Vec<_> = completion_dirs
173 .iter()
174 .filter(|(name, _)| name.contains("bash"))
175 .collect();
176
177 let user_dir = bash_dirs
179 .iter()
180 .find(|(name, _)| name.contains("user"))
181 .map(|(_, path)| path.clone())
182 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
183
184 let system_dir = if user_dir.is_none() {
186 bash_dirs
187 .iter()
188 .find(|(name, _)| name.contains("system"))
189 .map(|(_, path)| path.clone())
190 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
191 } else {
192 None
193 };
194
195 let dir = user_dir
196 .or(system_dir)
197 .or_else(|| {
198 dirs::home_dir().map(|h| h.join(".bash_completion.d"))
200 })
201 .ok_or_else(|| {
202 CascadeError::config("Could not find suitable bash completion directory")
203 })?;
204
205 (dir, "ca")
206 }
207 Shell::Zsh => {
208 let zsh_dirs: Vec<_> = completion_dirs
210 .iter()
211 .filter(|(name, _)| name.contains("zsh"))
212 .collect();
213
214 let user_dir = zsh_dirs
216 .iter()
217 .find(|(name, _)| name.contains("user"))
218 .map(|(_, path)| path.clone())
219 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
220
221 let system_dir = if user_dir.is_none() {
223 zsh_dirs
224 .iter()
225 .find(|(name, _)| name.contains("system"))
226 .map(|(_, path)| path.clone())
227 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
228 } else {
229 None
230 };
231
232 let dir = user_dir
233 .or(system_dir)
234 .or_else(|| {
235 dirs::home_dir().map(|h| h.join(".zsh/completions"))
237 })
238 .ok_or_else(|| {
239 CascadeError::config("Could not find suitable zsh completion directory")
240 })?;
241
242 (dir, "_ca")
243 }
244 Shell::Fish => {
245 let fish_dirs: Vec<_> = completion_dirs
247 .iter()
248 .filter(|(name, _)| name.contains("fish"))
249 .collect();
250
251 let user_dir = fish_dirs
253 .iter()
254 .find(|(name, _)| name.contains("user"))
255 .map(|(_, path)| path.clone())
256 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
257
258 let system_dir = if user_dir.is_none() {
260 fish_dirs
261 .iter()
262 .find(|(name, _)| name.contains("system"))
263 .map(|(_, path)| path.clone())
264 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
265 } else {
266 None
267 };
268
269 let dir = user_dir
270 .or(system_dir)
271 .or_else(|| {
272 dirs::home_dir().map(|h| h.join(".config/fish/completions"))
274 })
275 .ok_or_else(|| {
276 CascadeError::config("Could not find suitable fish completion directory")
277 })?;
278
279 (dir, "ca.fish")
280 }
281 _ => {
282 return Err(CascadeError::config(format!(
283 "Unsupported shell: {shell:?}"
284 )));
285 }
286 };
287
288 if !completion_dir.exists() {
290 fs::create_dir_all(&completion_dir)?;
291 }
292
293 let completion_file =
294 completion_dir.join(crate::utils::path_validation::sanitize_filename(filename));
295
296 crate::utils::path_validation::validate_config_path(&completion_file, &completion_dir)?;
298
299 let mut cmd = Cli::command();
301 let mut content = Vec::new();
302 generate(shell, &mut cmd, "ca", &mut content);
303
304 let custom_completion = generate_custom_completion(shell);
306 if !custom_completion.is_empty() {
307 content.extend_from_slice(custom_completion.as_bytes());
308 }
309
310 match crate::utils::atomic_file::write_bytes(&completion_file, &content) {
312 Ok(()) => {}
313 Err(e) if e.to_string().contains("Timeout waiting for lock") => {
314 if completion_dir.to_string_lossy().contains(
316 &dirs::home_dir()
317 .unwrap_or_default()
318 .to_string_lossy()
319 .to_string(),
320 ) {
321 std::fs::write(&completion_file, &content)?;
323 } else {
324 return Err(e);
326 }
327 }
328 Err(e) => return Err(e),
329 }
330
331 Ok(completion_file)
332}
333
334pub fn show_completions_status() -> Result<()> {
336 Output::section("Shell Completions Status");
337
338 let available_shells = detect_available_shells();
339
340 Output::section("Available shells");
341 for shell in &available_shells {
342 let status = check_completion_installed(*shell);
343 if status {
344 Output::success(format!("{shell:?}"));
345 } else {
346 Output::error(format!("{shell:?}"));
347 }
348 }
349
350 let all_installed = available_shells
351 .iter()
352 .all(|s| check_completion_installed(*s));
353
354 if !all_installed {
355 println!();
356 Output::tip("To install completions:");
357 Output::command_example("ca completions install");
358 Output::command_example("ca completions install --shell bash # for specific shell");
359 } else {
360 println!();
361 Output::success("All available shells have completions installed!");
362
363 if available_shells.contains(&Shell::Zsh) {
365 println!();
366
367 let zshrc_path = dirs::home_dir()
369 .map(|h| h.join(".zshrc"))
370 .unwrap_or_else(|| PathBuf::from("~/.zshrc"));
371
372 let mut needs_fpath = true;
373 let mut needs_compinit = true;
374
375 let mut using_omz = false;
376 let mut omz_line = None;
377
378 if let Ok(zshrc_content) = std::fs::read_to_string(&zshrc_path) {
379 if zshrc_content.contains("oh-my-zsh.sh") {
381 using_omz = true;
382 for (i, line) in zshrc_content.lines().enumerate() {
384 if line.contains("source") && line.contains("oh-my-zsh.sh") {
385 omz_line = Some(i + 1);
386 break;
387 }
388 }
389 }
390
391 if zshrc_content.contains("fpath=(~/.zsh/completions")
392 || zshrc_content.contains("fpath=(\"$HOME/.zsh/completions\"")
393 || zshrc_content.contains("fpath=($HOME/.zsh/completions")
394 {
395 needs_fpath = false;
396 }
397 if zshrc_content.contains("compinit") {
398 needs_compinit = false;
399 }
400 }
401
402 if needs_fpath || needs_compinit {
403 Output::warning("Zsh requires additional setup for completions to work");
404 println!();
405
406 if using_omz {
407 Output::sub_item("Detected Oh-My-Zsh - special setup required:");
408 println!();
409 if let Some(line_num) = omz_line {
410 Output::info(format!("Oh-My-Zsh loads at line {} in ~/.zshrc", line_num));
411 Output::sub_item("The fpath MUST be set BEFORE Oh-My-Zsh loads");
412 Output::sub_item(
413 "Oh-My-Zsh calls compinit internally, so DON'T add compinit yourself",
414 );
415 println!();
416 }
417
418 Output::sub_item("Option 1: Manual edit (recommended)");
419 Output::bullet("Open ~/.zshrc in an editor");
420 Output::bullet("Find the line: source $ZSH/oh-my-zsh.sh");
421 Output::bullet("Add this line BEFORE it:");
422 println!(" fpath=(~/.zsh/completions $fpath)");
423 Output::bullet("Make sure there's NO 'compinit' line at the end of ~/.zshrc");
424 Output::bullet("Save, then clear Oh-My-Zsh cache and reload:");
425 println!(" rm -f ~/.zcompdump && exec zsh");
426 println!();
427
428 Output::sub_item("Option 2: Automatic (requires sed)");
429 if let Some(line_num) = omz_line {
430 let insert_line = line_num;
431 Output::command_example(format!(
432 "sed -i.bak '{}i\\fpath=(~/.zsh/completions $fpath)' ~/.zshrc",
433 insert_line
434 ));
435 Output::command_example("rm -f ~/.zcompdump && exec zsh");
436 }
437 } else {
438 Output::sub_item("Run these commands to complete setup:");
439 println!();
440
441 if needs_fpath {
442 Output::command_example(
443 r#"echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc"#,
444 );
445 }
446 if needs_compinit {
447 Output::command_example(
448 r#"echo 'autoload -Uz compinit && compinit' >> ~/.zshrc"#,
449 );
450 }
451 Output::command_example("source ~/.zshrc");
452 }
453 } else {
454 Output::success("Zsh is properly configured for completions!");
455
456 if using_omz {
457 println!();
458 Output::tip("If completions aren't working, clear Oh-My-Zsh cache:");
459 Output::command_example("rm -f ~/.zcompdump && exec zsh");
460 }
461 }
462 }
463 }
464
465 println!();
466 Output::section("Manual installation");
467 Output::command_example("ca completions generate bash > ~/.bash_completion.d/ca");
468 Output::command_example("ca completions generate zsh > ~/.zsh/completions/_ca");
469 Output::command_example("ca completions generate fish > ~/.config/fish/completions/ca.fish");
470
471 Ok(())
472}
473
474fn check_completion_installed(shell: Shell) -> bool {
476 let home_dir = match dirs::home_dir() {
477 Some(dir) => dir,
478 None => return false,
479 };
480
481 let possible_paths = match shell {
482 Shell::Bash => vec![
483 home_dir.join(".bash_completion.d/ca"),
484 PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
485 PathBuf::from("/etc/bash_completion.d/ca"),
486 ],
487 Shell::Zsh => vec![
488 home_dir.join(".oh-my-zsh/completions/_ca"),
489 home_dir.join(".zsh/completions/_ca"),
490 PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
491 ],
492 Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
493 _ => return false,
494 };
495
496 possible_paths.iter().any(|path| path.exists())
497}
498
499fn generate_custom_completion(shell: Shell) -> String {
501 match shell {
502 Shell::Bash => {
503 r#"
504# Custom completion for ca switch command
505_ca_switch_completion() {
506 local cur="${COMP_WORDS[COMP_CWORD]}"
507 local stacks=$(ca completion-helper stack-names 2>/dev/null)
508 COMPREPLY=($(compgen -W "$stacks" -- "$cur"))
509}
510
511# Replace the default completion for 'ca switch' with our custom function
512complete -F _ca_switch_completion ca
513"#.to_string()
514 }
515 Shell::Zsh => {
516 r#"
517# Custom completion for ca switch command
518_ca_switch_completion() {
519 local stacks=($(ca completion-helper stack-names 2>/dev/null))
520 _describe 'stacks' stacks
521}
522
523# Override the switch completion
524compdef _ca_switch_completion ca switch
525
526# Explicitly bind the main completion function to 'ca'
527# This ensures the completion works even if Oh-My-Zsh or other plugins interfere
528compdef _ca ca
529"#.to_string()
530 }
531 Shell::Fish => {
532 r#"
533# Custom completion for ca switch command
534complete -c ca -f -n '__fish_seen_subcommand_from switch' -a '(ca completion-helper stack-names 2>/dev/null)'
535"#.to_string()
536 }
537 _ => String::new(),
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_detect_shells() {
547 let shells = detect_available_shells();
548 assert!(!shells.is_empty());
549 }
550
551 #[test]
552 fn test_generate_bash_completion() {
553 let result = generate_completions(Shell::Bash);
554 assert!(result.is_ok());
555 }
556
557 #[test]
558 fn test_detect_current_shell() {
559 std::env::set_var("SHELL", "/bin/zsh");
561 let shell = detect_current_shell();
562 assert_eq!(shell, Some(Shell::Zsh));
563
564 std::env::set_var("SHELL", "/usr/bin/bash");
565 let shell = detect_current_shell();
566 assert_eq!(shell, Some(Shell::Bash));
567
568 std::env::set_var("SHELL", "/usr/local/bin/fish");
569 let shell = detect_current_shell();
570 assert_eq!(shell, Some(Shell::Fish));
571
572 std::env::set_var("SHELL", "/bin/unknown");
573 let shell = detect_current_shell();
574 assert_eq!(shell, None);
575
576 std::env::remove_var("SHELL");
578 }
579}