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 Output::bullet("Restart your shell or run: source ~/.bashrc (or equivalent)");
52 Output::bullet("Try: ca <TAB><TAB>");
53 }
54
55 if !errors.is_empty() {
56 println!();
57 Output::warning("Some installations failed:");
58 for (shell, error) in errors {
59 Output::sub_item(format!("{shell:?}: {error}"));
60 }
61 }
62
63 Ok(())
64}
65
66fn detect_current_and_available_shells() -> Vec<Shell> {
68 let mut shells = Vec::new();
69
70 if let Some(current_shell) = detect_current_shell() {
72 shells.push(current_shell);
73 Output::info(format!("Detected current shell: {current_shell:?}"));
74 return shells; }
76
77 Output::info("Could not detect current shell, checking available shells...");
79 detect_available_shells()
80}
81
82fn detect_current_shell() -> Option<Shell> {
84 let shell_path = std::env::var("SHELL").ok()?;
85 let shell_name = std::path::Path::new(&shell_path).file_name()?.to_str()?;
86
87 match shell_name {
88 "bash" => Some(Shell::Bash),
89 "zsh" => Some(Shell::Zsh),
90 "fish" => Some(Shell::Fish),
91 _ => None,
92 }
93}
94
95fn detect_available_shells() -> Vec<Shell> {
97 let mut shells = Vec::new();
98
99 if which_shell("bash").is_some() {
101 shells.push(Shell::Bash);
102 }
103
104 if which_shell("zsh").is_some() {
106 shells.push(Shell::Zsh);
107 }
108
109 if which_shell("fish").is_some() {
111 shells.push(Shell::Fish);
112 }
113
114 if shells.is_empty() {
116 shells.push(Shell::Bash);
117 }
118
119 shells
120}
121
122fn which_shell(shell: &str) -> Option<PathBuf> {
124 std::env::var("PATH")
125 .ok()?
126 .split(crate::utils::platform::path_separator())
127 .map(PathBuf::from)
128 .find_map(|path| {
129 let shell_path = path.join(crate::utils::platform::executable_name(shell));
130 if crate::utils::platform::is_executable(&shell_path) {
131 Some(shell_path)
132 } else {
133 None
134 }
135 })
136}
137
138fn install_completion_for_shell(shell: Shell) -> Result<PathBuf> {
140 let completion_dirs = crate::utils::platform::shell_completion_dirs();
142
143 let (completion_dir, filename) = match shell {
144 Shell::Bash => {
145 let bash_dirs: Vec<_> = completion_dirs
147 .iter()
148 .filter(|(name, _)| name.contains("bash"))
149 .collect();
150
151 let user_dir = bash_dirs
153 .iter()
154 .find(|(name, _)| name.contains("user"))
155 .map(|(_, path)| path.clone())
156 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
157
158 let system_dir = if user_dir.is_none() {
160 bash_dirs
161 .iter()
162 .find(|(name, _)| name.contains("system"))
163 .map(|(_, path)| path.clone())
164 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
165 } else {
166 None
167 };
168
169 let dir = user_dir
170 .or(system_dir)
171 .or_else(|| {
172 dirs::home_dir().map(|h| h.join(".bash_completion.d"))
174 })
175 .ok_or_else(|| {
176 CascadeError::config("Could not find suitable bash completion directory")
177 })?;
178
179 (dir, "ca")
180 }
181 Shell::Zsh => {
182 let zsh_dirs: Vec<_> = completion_dirs
184 .iter()
185 .filter(|(name, _)| name.contains("zsh"))
186 .collect();
187
188 let user_dir = zsh_dirs
190 .iter()
191 .find(|(name, _)| name.contains("user"))
192 .map(|(_, path)| path.clone())
193 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
194
195 let system_dir = if user_dir.is_none() {
197 zsh_dirs
198 .iter()
199 .find(|(name, _)| name.contains("system"))
200 .map(|(_, path)| path.clone())
201 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
202 } else {
203 None
204 };
205
206 let dir = user_dir
207 .or(system_dir)
208 .or_else(|| {
209 dirs::home_dir().map(|h| h.join(".zsh/completions"))
211 })
212 .ok_or_else(|| {
213 CascadeError::config("Could not find suitable zsh completion directory")
214 })?;
215
216 (dir, "_ca")
217 }
218 Shell::Fish => {
219 let fish_dirs: Vec<_> = completion_dirs
221 .iter()
222 .filter(|(name, _)| name.contains("fish"))
223 .collect();
224
225 let user_dir = fish_dirs
227 .iter()
228 .find(|(name, _)| name.contains("user"))
229 .map(|(_, path)| path.clone())
230 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()));
231
232 let system_dir = if user_dir.is_none() {
234 fish_dirs
235 .iter()
236 .find(|(name, _)| name.contains("system"))
237 .map(|(_, path)| path.clone())
238 .filter(|d| d.exists() || d.parent().is_some_and(|p| p.exists()))
239 } else {
240 None
241 };
242
243 let dir = user_dir
244 .or(system_dir)
245 .or_else(|| {
246 dirs::home_dir().map(|h| h.join(".config/fish/completions"))
248 })
249 .ok_or_else(|| {
250 CascadeError::config("Could not find suitable fish completion directory")
251 })?;
252
253 (dir, "ca.fish")
254 }
255 _ => {
256 return Err(CascadeError::config(format!(
257 "Unsupported shell: {shell:?}"
258 )));
259 }
260 };
261
262 if !completion_dir.exists() {
264 fs::create_dir_all(&completion_dir)?;
265 }
266
267 let completion_file =
268 completion_dir.join(crate::utils::path_validation::sanitize_filename(filename));
269
270 crate::utils::path_validation::validate_config_path(&completion_file, &completion_dir)?;
272
273 let mut cmd = Cli::command();
275 let mut content = Vec::new();
276 generate(shell, &mut cmd, "ca", &mut content);
277
278 let custom_completion = generate_custom_completion(shell);
280 if !custom_completion.is_empty() {
281 content.extend_from_slice(custom_completion.as_bytes());
282 }
283
284 match crate::utils::atomic_file::write_bytes(&completion_file, &content) {
286 Ok(()) => {}
287 Err(e) if e.to_string().contains("Timeout waiting for lock") => {
288 if completion_dir.to_string_lossy().contains(
290 &dirs::home_dir()
291 .unwrap_or_default()
292 .to_string_lossy()
293 .to_string(),
294 ) {
295 std::fs::write(&completion_file, &content)?;
297 } else {
298 return Err(e);
300 }
301 }
302 Err(e) => return Err(e),
303 }
304
305 Ok(completion_file)
306}
307
308pub fn show_completions_status() -> Result<()> {
310 Output::section("Shell Completions Status");
311
312 let available_shells = detect_available_shells();
313
314 Output::section("Available shells");
315 for shell in &available_shells {
316 let status = check_completion_installed(*shell);
317 if status {
318 Output::success(format!("{shell:?}"));
319 } else {
320 Output::error(format!("{shell:?}"));
321 }
322 }
323
324 if available_shells
325 .iter()
326 .any(|s| !check_completion_installed(*s))
327 {
328 println!();
329 Output::tip("To install completions:");
330 Output::command_example("ca completions install");
331 Output::command_example("ca completions install --shell bash # for specific shell");
332 } else {
333 println!();
334 Output::success("All available shells have completions installed!");
335 }
336
337 println!();
338 Output::section("Manual installation");
339 Output::command_example("ca completions generate bash > ~/.bash_completion.d/ca");
340 Output::command_example("ca completions generate zsh > ~/.zsh/completions/_ca");
341 Output::command_example("ca completions generate fish > ~/.config/fish/completions/ca.fish");
342
343 Ok(())
344}
345
346fn check_completion_installed(shell: Shell) -> bool {
348 let home_dir = match dirs::home_dir() {
349 Some(dir) => dir,
350 None => return false,
351 };
352
353 let possible_paths = match shell {
354 Shell::Bash => vec![
355 home_dir.join(".bash_completion.d/ca"),
356 PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
357 PathBuf::from("/etc/bash_completion.d/ca"),
358 ],
359 Shell::Zsh => vec![
360 home_dir.join(".oh-my-zsh/completions/_ca"),
361 home_dir.join(".zsh/completions/_ca"),
362 PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
363 ],
364 Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
365 _ => return false,
366 };
367
368 possible_paths.iter().any(|path| path.exists())
369}
370
371fn generate_custom_completion(shell: Shell) -> String {
373 match shell {
374 Shell::Bash => {
375 r#"
376# Custom completion for ca switch command
377_ca_switch_completion() {
378 local cur="${COMP_WORDS[COMP_CWORD]}"
379 local stacks=$(ca completion-helper stack-names 2>/dev/null)
380 COMPREPLY=($(compgen -W "$stacks" -- "$cur"))
381}
382
383# Replace the default completion for 'ca switch' with our custom function
384complete -F _ca_switch_completion ca
385"#.to_string()
386 }
387 Shell::Zsh => {
388 r#"
389# Custom completion for ca switch command
390_ca_switch_completion() {
391 local stacks=($(ca completion-helper stack-names 2>/dev/null))
392 _describe 'stacks' stacks
393}
394
395# Override the switch completion
396compdef _ca_switch_completion ca switch
397"#.to_string()
398 }
399 Shell::Fish => {
400 r#"
401# Custom completion for ca switch command
402complete -c ca -f -n '__fish_seen_subcommand_from switch' -a '(ca completion-helper stack-names 2>/dev/null)'
403"#.to_string()
404 }
405 _ => String::new(),
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn test_detect_shells() {
415 let shells = detect_available_shells();
416 assert!(!shells.is_empty());
417 }
418
419 #[test]
420 fn test_generate_bash_completion() {
421 let result = generate_completions(Shell::Bash);
422 assert!(result.is_ok());
423 }
424
425 #[test]
426 fn test_detect_current_shell() {
427 std::env::set_var("SHELL", "/bin/zsh");
429 let shell = detect_current_shell();
430 assert_eq!(shell, Some(Shell::Zsh));
431
432 std::env::set_var("SHELL", "/usr/bin/bash");
433 let shell = detect_current_shell();
434 assert_eq!(shell, Some(Shell::Bash));
435
436 std::env::set_var("SHELL", "/usr/local/bin/fish");
437 let shell = detect_current_shell();
438 assert_eq!(shell, Some(Shell::Fish));
439
440 std::env::set_var("SHELL", "/bin/unknown");
441 let shell = detect_current_shell();
442 assert_eq!(shell, None);
443
444 std::env::remove_var("SHELL");
446 }
447}