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 println!();
363 Output::warning("⚠️ Important: Just having the file installed doesn't mean it works!");
364 Output::tip("For zsh users, you must also:");
365 Output::bullet("Add 'fpath=(~/.zsh/completions $fpath)' to ~/.zshrc");
366 Output::bullet("Add 'autoload -Uz compinit && compinit' to ~/.zshrc");
367 Output::bullet("Then run 'source ~/.zshrc'");
368 }
369
370 println!();
371 Output::section("Manual installation");
372 Output::command_example("ca completions generate bash > ~/.bash_completion.d/ca");
373 Output::command_example("ca completions generate zsh > ~/.zsh/completions/_ca");
374 Output::command_example("ca completions generate fish > ~/.config/fish/completions/ca.fish");
375
376 Ok(())
377}
378
379fn check_completion_installed(shell: Shell) -> bool {
381 let home_dir = match dirs::home_dir() {
382 Some(dir) => dir,
383 None => return false,
384 };
385
386 let possible_paths = match shell {
387 Shell::Bash => vec![
388 home_dir.join(".bash_completion.d/ca"),
389 PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
390 PathBuf::from("/etc/bash_completion.d/ca"),
391 ],
392 Shell::Zsh => vec![
393 home_dir.join(".oh-my-zsh/completions/_ca"),
394 home_dir.join(".zsh/completions/_ca"),
395 PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
396 ],
397 Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
398 _ => return false,
399 };
400
401 possible_paths.iter().any(|path| path.exists())
402}
403
404fn generate_custom_completion(shell: Shell) -> String {
406 match shell {
407 Shell::Bash => {
408 r#"
409# Custom completion for ca switch command
410_ca_switch_completion() {
411 local cur="${COMP_WORDS[COMP_CWORD]}"
412 local stacks=$(ca completion-helper stack-names 2>/dev/null)
413 COMPREPLY=($(compgen -W "$stacks" -- "$cur"))
414}
415
416# Replace the default completion for 'ca switch' with our custom function
417complete -F _ca_switch_completion ca
418"#.to_string()
419 }
420 Shell::Zsh => {
421 r#"
422# Custom completion for ca switch command
423_ca_switch_completion() {
424 local stacks=($(ca completion-helper stack-names 2>/dev/null))
425 _describe 'stacks' stacks
426}
427
428# Override the switch completion
429compdef _ca_switch_completion ca switch
430"#.to_string()
431 }
432 Shell::Fish => {
433 r#"
434# Custom completion for ca switch command
435complete -c ca -f -n '__fish_seen_subcommand_from switch' -a '(ca completion-helper stack-names 2>/dev/null)'
436"#.to_string()
437 }
438 _ => String::new(),
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn test_detect_shells() {
448 let shells = detect_available_shells();
449 assert!(!shells.is_empty());
450 }
451
452 #[test]
453 fn test_generate_bash_completion() {
454 let result = generate_completions(Shell::Bash);
455 assert!(result.is_ok());
456 }
457
458 #[test]
459 fn test_detect_current_shell() {
460 std::env::set_var("SHELL", "/bin/zsh");
462 let shell = detect_current_shell();
463 assert_eq!(shell, Some(Shell::Zsh));
464
465 std::env::set_var("SHELL", "/usr/bin/bash");
466 let shell = detect_current_shell();
467 assert_eq!(shell, Some(Shell::Bash));
468
469 std::env::set_var("SHELL", "/usr/local/bin/fish");
470 let shell = detect_current_shell();
471 assert_eq!(shell, Some(Shell::Fish));
472
473 std::env::set_var("SHELL", "/bin/unknown");
474 let shell = detect_current_shell();
475 assert_eq!(shell, None);
476
477 std::env::remove_var("SHELL");
479 }
480}