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 match crate::utils::atomic_file::write_bytes(&completion_file, &content) {
277 Ok(()) => {}
278 Err(e) if e.to_string().contains("Timeout waiting for lock") => {
279 if completion_dir.to_string_lossy().contains(
281 &dirs::home_dir()
282 .unwrap_or_default()
283 .to_string_lossy()
284 .to_string(),
285 ) {
286 std::fs::write(&completion_file, &content)?;
288 } else {
289 return Err(e);
291 }
292 }
293 Err(e) => return Err(e),
294 }
295
296 Ok(completion_file)
297}
298
299pub fn show_completions_status() -> Result<()> {
301 println!("š Shell Completions Status");
302 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāā");
303
304 let available_shells = detect_available_shells();
305
306 println!("\nš Available shells:");
307 for shell in &available_shells {
308 let status = check_completion_installed(*shell);
309 let status_icon = if status { "ā
" } else { "ā" };
310 println!(" {status_icon} {shell:?}");
311 }
312
313 if available_shells
314 .iter()
315 .any(|s| !check_completion_installed(*s))
316 {
317 println!("\nš” To install completions:");
318 println!(" ca completions install");
319 println!(" ca completions install --shell bash # for specific shell");
320 } else {
321 println!("\nš All available shells have completions installed!");
322 }
323
324 println!("\nš§ Manual installation:");
325 println!(" ca completions generate bash > ~/.bash_completion.d/ca");
326 println!(" ca completions generate zsh > ~/.zsh/completions/_ca");
327 println!(" ca completions generate fish > ~/.config/fish/completions/ca.fish");
328
329 Ok(())
330}
331
332fn check_completion_installed(shell: Shell) -> bool {
334 let home_dir = match dirs::home_dir() {
335 Some(dir) => dir,
336 None => return false,
337 };
338
339 let possible_paths = match shell {
340 Shell::Bash => vec![
341 home_dir.join(".bash_completion.d/ca"),
342 PathBuf::from("/usr/local/etc/bash_completion.d/ca"),
343 PathBuf::from("/etc/bash_completion.d/ca"),
344 ],
345 Shell::Zsh => vec![
346 home_dir.join(".oh-my-zsh/completions/_ca"),
347 home_dir.join(".zsh/completions/_ca"),
348 PathBuf::from("/usr/local/share/zsh/site-functions/_ca"),
349 ],
350 Shell::Fish => vec![home_dir.join(".config/fish/completions/ca.fish")],
351 _ => return false,
352 };
353
354 possible_paths.iter().any(|path| path.exists())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_detect_shells() {
363 let shells = detect_available_shells();
364 assert!(!shells.is_empty());
365 }
366
367 #[test]
368 fn test_generate_bash_completion() {
369 let result = generate_completions(Shell::Bash);
370 assert!(result.is_ok());
371 }
372
373 #[test]
374 fn test_detect_current_shell() {
375 std::env::set_var("SHELL", "/bin/zsh");
377 let shell = detect_current_shell();
378 assert_eq!(shell, Some(Shell::Zsh));
379
380 std::env::set_var("SHELL", "/usr/bin/bash");
381 let shell = detect_current_shell();
382 assert_eq!(shell, Some(Shell::Bash));
383
384 std::env::set_var("SHELL", "/usr/local/bin/fish");
385 let shell = detect_current_shell();
386 assert_eq!(shell, Some(Shell::Fish));
387
388 std::env::set_var("SHELL", "/bin/unknown");
389 let shell = detect_current_shell();
390 assert_eq!(shell, None);
391
392 std::env::remove_var("SHELL");
394 }
395}