1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Shell {
9 Bash,
10 Zsh,
11 Fish,
12}
13
14pub fn generate_completion_script(
20 shell: Shell,
21 domains: &[(String, Vec<String>)],
22 global_flags: &[(&str, Option<char>)],
23) -> String {
24 match shell {
25 Shell::Bash => generate_bash(domains, global_flags),
26 Shell::Zsh => generate_zsh(domains, global_flags),
27 Shell::Fish => generate_fish(domains, global_flags),
28 }
29}
30
31pub fn complete_partial(
38 tokens: &[&str],
39 domains: &[(String, Vec<(String, Vec<String>)>)],
40) -> Vec<String> {
41 let domain_names: Vec<&str> = domains.iter().map(|(n, _)| n.as_str()).collect();
42
43 match tokens.len() {
44 0 => domain_names.iter().map(|s| s.to_string()).collect(),
46
47 1 => {
49 let prefix = tokens[0];
50 domain_names
51 .iter()
52 .filter(|d| d.starts_with(prefix))
53 .map(|d| d.to_string())
54 .collect()
55 }
56
57 2 => {
59 let domain = tokens[0];
60 let prefix = tokens[1];
61 domains
62 .iter()
63 .find(|(name, _)| name == domain)
64 .map(|(_, resources)| {
65 resources
66 .iter()
67 .map(|(r, _)| r.as_str())
68 .filter(|r| r.starts_with(prefix))
69 .map(|r| r.to_string())
70 .collect()
71 })
72 .unwrap_or_default()
73 }
74
75 3 => {
77 let domain = tokens[0];
78 let prefix = tokens[2];
79 domains
80 .iter()
81 .find(|(name, _)| name == domain)
82 .and_then(|(_, resources)| {
83 resources
84 .iter()
85 .find(|(r, _)| r == tokens[1])
86 .map(|(_, verbs)| {
87 verbs
88 .iter()
89 .filter(|v| v.starts_with(prefix))
90 .cloned()
91 .collect()
92 })
93 })
94 .unwrap_or_default()
95 }
96
97 _ => {
99 let last = *tokens.last().unwrap_or(&"");
100 if let Some(prefix) = last.strip_prefix("--") {
101 let flag_names = ["help", "json", "output", "verbose", "no-color", "version"];
102 flag_names
103 .iter()
104 .filter(|f| f.starts_with(prefix))
105 .map(|f| format!("--{}", f))
106 .collect()
107 } else if last.starts_with('-') && last.len() == 1 {
108 vec![
109 "-h".to_string(),
110 "-j".to_string(),
111 "-o".to_string(),
112 "-v".to_string(),
113 ]
114 } else {
115 Vec::new()
116 }
117 }
118 }
119}
120
121fn generate_bash(
126 domains: &[(String, Vec<String>)],
127 global_flags: &[(&str, Option<char>)],
128) -> String {
129 let all_domains: Vec<&str> = domains.iter().map(|(n, _)| n.as_str()).collect();
130 let domain_word_list = all_domains.join(" ");
131
132 let flag_word_list: String = global_flags
133 .iter()
134 .map(|(long, short)| {
135 let mut parts = vec![format!("--{}", long)];
136 if let Some(ch) = short {
137 parts.push(format!("-{}", ch));
138 }
139 parts.join(" ")
140 })
141 .collect::<Vec<_>>()
142 .join(" ");
143
144 format!(
145 r#"_red_completions() {{
146 local cur prev words cword
147 _init_completion || return
148
149 # Global flags at any position
150 if [[ "$cur" == -* ]]; then
151 COMPREPLY=($(compgen -W "{flags}" -- "$cur"))
152 return
153 fi
154
155 case $cword in
156 1)
157 COMPREPLY=($(compgen -W "{domains} help version" -- "$cur"))
158 ;;
159 *)
160 # Delegate deeper completions to the binary when available
161 if command -v red &>/dev/null; then
162 local completions
163 completions=$(red --complete "${{words[@]:1}}" 2>/dev/null)
164 if [[ -n "$completions" ]]; then
165 COMPREPLY=($(compgen -W "$completions" -- "$cur"))
166 fi
167 fi
168 ;;
169 esac
170}}
171complete -F _red_completions red
172"#,
173 flags = flag_word_list,
174 domains = domain_word_list,
175 )
176}
177
178fn generate_zsh(
183 domains: &[(String, Vec<String>)],
184 global_flags: &[(&str, Option<char>)],
185) -> String {
186 let mut out = String::with_capacity(1024);
187
188 out.push_str("#compdef red\n\n");
189 out.push_str("_red() {\n");
190 out.push_str(" local -a global_flags\n");
191 out.push_str(" global_flags=(\n");
192 for (long, short) in global_flags {
193 match short {
194 Some(ch) => {
195 out.push_str(&format!(
196 " '(-{ch} --{long})'{{-{ch},--{long}}}'[{long}]'\n",
197 ch = ch,
198 long = long,
199 ));
200 }
201 None => {
202 out.push_str(&format!(" '--{long}[{long}]'\n", long = long));
203 }
204 }
205 }
206 out.push_str(" )\n\n");
207
208 out.push_str(" _arguments -C \\\n");
209 out.push_str(" $global_flags \\\n");
210 out.push_str(" '1:command:->command' \\\n");
211 out.push_str(" '*::arg:->args'\n\n");
212
213 out.push_str(" case $state in\n");
214 out.push_str(" command)\n");
215 out.push_str(" local -a commands\n");
216 out.push_str(" commands=(\n");
217 for (name, _) in domains {
218 out.push_str(&format!(" '{}'\n", name));
219 }
220 out.push_str(" 'help'\n");
221 out.push_str(" 'version'\n");
222 out.push_str(" )\n");
223 out.push_str(" _describe 'command' commands\n");
224 out.push_str(" ;;\n");
225
226 out.push_str(" args)\n");
227 out.push_str(" # Delegate to binary for deeper completions\n");
228 out.push_str(" if (( $+commands[red] )); then\n");
229 out.push_str(" local completions\n");
230 out.push_str(
231 " completions=(${(f)\"$(red --complete ${words[2,-1]} 2>/dev/null)\"})\n",
232 );
233 out.push_str(" _describe 'subcommand' completions\n");
234 out.push_str(" fi\n");
235 out.push_str(" ;;\n");
236 out.push_str(" esac\n");
237 out.push_str("}\n\n");
238 out.push_str("_red\n");
239 out
240}
241
242fn generate_fish(
247 domains: &[(String, Vec<String>)],
248 global_flags: &[(&str, Option<char>)],
249) -> String {
250 let mut out = String::with_capacity(1024);
251
252 out.push_str("# Fish completions for red (reddb)\n\n");
253
254 for (long, short) in global_flags {
256 match short {
257 Some(ch) => {
258 out.push_str(&format!(
259 "complete -c red -s {} -l {} -d '{}'\n",
260 ch, long, long
261 ));
262 }
263 None => {
264 out.push_str(&format!("complete -c red -l {} -d '{}'\n", long, long));
265 }
266 }
267 }
268 out.push('\n');
269
270 out.push_str("# Command completions\n");
272 for (name, _) in domains {
273 out.push_str(&format!(
274 "complete -c red -n '__fish_use_subcommand' -a {} -d '{}'\n",
275 name, name
276 ));
277 }
278 out.push_str("complete -c red -n '__fish_use_subcommand' -a help -d 'Show help'\n");
279 out.push_str("complete -c red -n '__fish_use_subcommand' -a version -d 'Show version'\n");
280 out.push('\n');
281
282 out.push_str("# Delegate deeper completions to the binary\n");
284 out.push_str("complete -c red -n 'not __fish_use_subcommand' -a '(red --complete (commandline -cop) 2>/dev/null)'\n");
285
286 out
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn sample_domains() -> Vec<(String, Vec<String>)> {
294 vec![
295 ("server".to_string(), vec![]),
296 ("query".to_string(), vec!["q".to_string()]),
297 ("health".to_string(), vec![]),
298 ]
299 }
300
301 fn sample_global_flags() -> Vec<(&'static str, Option<char>)> {
302 vec![
303 ("help", Some('h')),
304 ("json", Some('j')),
305 ("output", Some('o')),
306 ("verbose", Some('v')),
307 ("no-color", None),
308 ]
309 }
310
311 fn sample_domain_tree() -> Vec<(String, Vec<(String, Vec<String>)>)> {
312 vec![
313 (
314 "server".to_string(),
315 vec![(
316 "grpc".to_string(),
317 vec!["start".to_string(), "stop".to_string()],
318 )],
319 ),
320 (
321 "query".to_string(),
322 vec![
323 (
324 "sql".to_string(),
325 vec!["execute".to_string(), "explain".to_string()],
326 ),
327 ("graph".to_string(), vec!["traverse".to_string()]),
328 ],
329 ),
330 (
331 "health".to_string(),
332 vec![(
333 "check".to_string(),
334 vec!["status".to_string(), "ping".to_string()],
335 )],
336 ),
337 ]
338 }
339
340 #[test]
345 fn test_complete_partial_domains() {
346 let tree = sample_domain_tree();
347 let result = complete_partial(&[], &tree);
348 assert!(result.contains(&"server".to_string()));
349 assert!(result.contains(&"query".to_string()));
350 assert!(result.contains(&"health".to_string()));
351 assert_eq!(result.len(), 3);
352 }
353
354 #[test]
355 fn test_complete_partial_domains_filter() {
356 let tree = sample_domain_tree();
357 let result = complete_partial(&["s"], &tree);
358 assert_eq!(result, vec!["server".to_string()]);
359 }
360
361 #[test]
362 fn test_complete_partial_resources() {
363 let tree = sample_domain_tree();
364 let result = complete_partial(&["query", ""], &tree);
365 assert!(result.contains(&"sql".to_string()));
366 assert!(result.contains(&"graph".to_string()));
367 }
368
369 #[test]
370 fn test_complete_partial_resources_filter() {
371 let tree = sample_domain_tree();
372 let result = complete_partial(&["query", "s"], &tree);
373 assert_eq!(result, vec!["sql".to_string()]);
374 }
375
376 #[test]
377 fn test_complete_partial_verbs() {
378 let tree = sample_domain_tree();
379 let result = complete_partial(&["server", "grpc", ""], &tree);
380 assert!(result.contains(&"start".to_string()));
381 assert!(result.contains(&"stop".to_string()));
382 }
383
384 #[test]
385 fn test_complete_partial_verbs_filter() {
386 let tree = sample_domain_tree();
387 let result = complete_partial(&["server", "grpc", "sta"], &tree);
388 assert_eq!(result, vec!["start".to_string()]);
389 }
390
391 #[test]
392 fn test_complete_partial_flags() {
393 let tree = sample_domain_tree();
394 let result = complete_partial(&["server", "grpc", "start", "--"], &tree);
395 assert!(result.contains(&"--help".to_string()));
397 assert!(result.contains(&"--json".to_string()));
398 assert!(result.contains(&"--verbose".to_string()));
399 }
400
401 #[test]
402 fn test_complete_partial_unknown_domain() {
403 let tree = sample_domain_tree();
404 let result = complete_partial(&["unknown", ""], &tree);
405 assert!(result.is_empty());
406 }
407
408 #[test]
413 fn test_bash_completion_script() {
414 let script =
415 generate_completion_script(Shell::Bash, &sample_domains(), &sample_global_flags());
416 assert!(script.contains("_red_completions()"));
417 assert!(script.contains("complete -F _red_completions red"));
418 assert!(script.contains("server"));
419 assert!(script.contains("query"));
420 assert!(script.contains("health"));
421 assert!(script.contains("--help"));
422 assert!(script.contains("-h"));
423 assert!(script.contains("help version"));
424 }
425
426 #[test]
431 fn test_zsh_completion_script() {
432 let script =
433 generate_completion_script(Shell::Zsh, &sample_domains(), &sample_global_flags());
434 assert!(script.contains("#compdef red"));
435 assert!(script.contains("_red()"));
436 assert!(script.contains("_arguments"));
437 assert!(script.contains("server"));
438 assert!(script.contains("query"));
439 assert!(script.contains("health"));
440 assert!(script.contains("--help"));
441 }
442
443 #[test]
448 fn test_fish_completion_script() {
449 let script =
450 generate_completion_script(Shell::Fish, &sample_domains(), &sample_global_flags());
451 assert!(script.contains("complete -c red"));
452 assert!(script.contains("-s h -l help"));
453 assert!(script.contains("__fish_use_subcommand"));
454 assert!(script.contains("server"));
455 assert!(script.contains("query"));
456 }
457}