1use std::path::PathBuf;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use super::model::{ArgumentSpec, CommandGraph, CommandNode, Origin, Surface, ValueSource};
33use super::policy;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37pub struct Request {
38 pub surface: Surface,
40 pub words: Vec<String>,
42 pub cursor: usize,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cwd: Option<PathBuf>,
48}
49
50impl Request {
51 pub fn prefix(&self) -> &str {
53 self.words
54 .get(self.cursor)
55 .map(String::as_str)
56 .unwrap_or("")
57 }
58
59 pub fn preceding(&self) -> &[String] {
61 let end = self.cursor.min(self.words.len());
62 let start = 1.min(end);
63 &self.words[start..end]
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "snake_case")]
70pub enum CandidateKind {
71 Command,
73 Workflow,
75 Flag,
77 Value,
79 Path,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85pub struct Candidate {
86 pub value: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub description: Option<String>,
91 pub kind: CandidateKind,
93 pub append_space: bool,
95 pub priority: i32,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101pub struct Response {
102 pub candidates: Vec<Candidate>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub resolved: Option<String>,
107 pub files: bool,
109}
110
111pub trait ValueResolver {
118 fn values(&self, source: ValueSource) -> Vec<String>;
120}
121
122pub struct NoValues;
128
129impl ValueResolver for NoValues {
130 fn values(&self, _source: ValueSource) -> Vec<String> {
131 Vec::new()
132 }
133}
134
135pub fn complete(graph: &CommandGraph, request: &Request, values: &dyn ValueResolver) -> Response {
137 let prefix = request.prefix().to_string();
138 let words = request.preceding();
139
140 match request.surface {
141 Surface::Workflow => complete_workflow(graph, &prefix, words, values),
142 _ => complete_cli(graph, &prefix, words, values),
143 }
144}
145
146fn complete_cli(
148 graph: &CommandGraph,
149 prefix: &str,
150 words: &[String],
151 values: &dyn ValueResolver,
152) -> Response {
153 let mut path: Vec<String> = Vec::new();
157 let mut resolved: Option<&CommandNode> = None;
158 let mut consumed = 0usize;
159 for (i, word) in words.iter().enumerate() {
160 if word.starts_with('-') {
161 break;
162 }
163 let mut candidate_path = path.clone();
164 candidate_path.push(word.clone());
165 match graph.commands.iter().find(|c| {
166 matches!(c.origin, Origin::Executable | Origin::Tool) && c.path == candidate_path
167 }) {
168 Some(node) => {
169 path = candidate_path;
170 resolved = Some(node);
171 consumed = i + 1;
172 }
173 None => break,
174 }
175 }
176 let rest = &words[consumed.min(words.len())..];
177 candidates_for(graph, resolved, &path, rest, prefix, values, false)
178}
179
180fn complete_workflow(
182 graph: &CommandGraph,
183 prefix: &str,
184 words: &[String],
185 values: &dyn ValueResolver,
186) -> Response {
187 let Some(name) = words.first() else {
191 let mut candidates: Vec<Candidate> = graph
192 .commands
193 .iter()
194 .filter(|c| policy::offered_on(c, Surface::Workflow))
195 .filter_map(|c| {
196 let name = c.projections.workflow.clone()?;
197 Some(Candidate {
198 description: Some(summary(c)),
199 kind: CandidateKind::Workflow,
200 append_space: true,
201 priority: priority_of(c),
202 value: name,
203 })
204 })
205 .collect();
206 return finish(&mut candidates, prefix, None, false);
207 };
208 let Some(node) = graph.resolve(Surface::Workflow, name) else {
209 return Response {
210 candidates: Vec::new(),
211 resolved: None,
212 files: true,
213 };
214 };
215 let path = node.path.clone();
216 candidates_for(graph, Some(node), &path, &words[1..], prefix, values, true)
217}
218
219#[allow(clippy::too_many_arguments)]
221fn candidates_for<'a>(
222 graph: &'a CommandGraph,
223 node: Option<&'a CommandNode>,
224 path: &[String],
225 rest: &[String],
226 prefix: &str,
227 values: &dyn ValueResolver,
228 in_workflow: bool,
229) -> Response {
230 let mut out: Vec<Candidate> = Vec::new();
231 let mut files = false;
232
233 if let Some(previous) = rest.last() {
235 if previous.starts_with('-') {
236 if let Some(arg) = node.and_then(|n| flag(n, previous)) {
237 if arg.takes_value {
238 let (mut vals, wants_files) = value_candidates(arg, values);
239 return finish(&mut vals, prefix, node, wants_files);
240 }
241 }
242 }
243 }
244
245 if prefix.starts_with('-') {
246 if let Some(node) = node {
247 for arg in &node.arguments {
248 if arg.positional {
249 continue;
250 }
251 if let Some(long) = &arg.long {
252 out.push(Candidate {
253 value: format!("--{long}"),
254 description: Some(arg.help.clone()),
255 kind: CandidateKind::Flag,
256 append_space: !arg.takes_value,
257 priority: if arg.required { 10 } else { 20 },
258 });
259 }
260 }
261 }
262 return finish(&mut out, prefix, node, false);
263 }
264
265 if !in_workflow {
268 for child in graph.commands.iter().filter(|c| {
269 matches!(c.origin, Origin::Executable | Origin::Tool)
270 && c.path.len() == path.len() + 1
271 && c.path.starts_with(path)
272 }) {
273 out.push(Candidate {
274 value: child.path.last().cloned().unwrap_or_default(),
275 description: Some(summary(child)),
276 kind: CandidateKind::Command,
277 append_space: true,
278 priority: priority_of(child),
279 });
280 }
281 if let Some(node) = node {
282 for alias in &node.aliases {
283 out.push(Candidate {
284 value: alias.clone(),
285 description: Some(format!("alias of {}", node.invocation)),
286 kind: CandidateKind::Command,
287 append_space: true,
288 priority: 60,
289 });
290 }
291 }
292 }
293
294 if let Some(node) = node {
296 let given = rest.iter().filter(|w| !w.starts_with('-')).count();
297 if let Some(arg) = node.arguments.iter().filter(|a| a.positional).nth(given) {
298 let (mut vals, wants_files) = value_candidates(arg, values);
299 files |= wants_files;
300 out.append(&mut vals);
301 }
302 }
303
304 finish(&mut out, prefix, node, files)
305}
306
307fn value_candidates(arg: &ArgumentSpec, values: &dyn ValueResolver) -> (Vec<Candidate>, bool) {
309 if !arg.source.suggestible() {
313 return (Vec::new(), matches!(arg.source, ValueSource::Free));
314 }
315 match arg.source {
316 ValueSource::Enumerated => (
317 arg.values
318 .iter()
319 .map(|v| Candidate {
320 value: v.value.clone(),
321 description: v.description.clone(),
322 kind: CandidateKind::Value,
323 append_space: true,
324 priority: 30,
325 })
326 .collect(),
327 false,
328 ),
329 ValueSource::Path | ValueSource::RepositoryPath => (Vec::new(), true),
330 other => (
331 values
332 .values(other)
333 .into_iter()
334 .map(|v| Candidate {
335 value: v,
336 description: None,
337 kind: CandidateKind::Value,
338 append_space: true,
339 priority: 30,
340 })
341 .collect(),
342 false,
343 ),
344 }
345}
346
347fn flag<'a>(node: &'a CommandNode, word: &str) -> Option<&'a ArgumentSpec> {
349 let bare = word.trim_start_matches('-');
350 node.arguments.iter().find(|a| {
351 a.long.as_deref() == Some(bare) || a.short.map(|c| c.to_string()).as_deref() == Some(bare)
352 })
353}
354
355fn summary(node: &CommandNode) -> String {
357 match (&node.availability.available, &node.availability.reason) {
358 (false, Some(reason)) => format!("{} (unavailable: {reason})", node.summary),
359 _ => node.summary.clone(),
360 }
361}
362
363fn priority_of(node: &CommandNode) -> i32 {
369 let mut p = match node.origin {
370 Origin::Workflow => 0,
371 Origin::Tool => 10,
372 Origin::Executable => 10,
373 };
374 if node.entrypoint == Some(true) {
375 p -= 5;
376 }
377 if !node.availability.available {
378 p += 100;
379 }
380 p + (node.effect as i32)
381}
382
383fn finish(
385 candidates: &mut Vec<Candidate>,
386 prefix: &str,
387 node: Option<&CommandNode>,
388 files: bool,
389) -> Response {
390 candidates.retain(|c| c.value.starts_with(prefix));
391 candidates.sort_by(|a, b| (a.priority, &a.value).cmp(&(b.priority, &b.value)));
392 candidates.dedup_by(|a, b| a.value == b.value);
393 Response {
394 candidates: std::mem::take(candidates),
395 resolved: node.map(|n| n.id.to_string()),
396 files,
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::command_graph::{build, Inputs};
404
405 fn words(line: &str) -> Vec<String> {
406 line.split_whitespace().map(String::from).collect()
407 }
408
409 fn request(surface: Surface, line: &str, trailing_space: bool) -> Request {
410 let words = words(line);
411 let cursor = if trailing_space {
412 words.len()
413 } else {
414 words.len().saturating_sub(1)
415 };
416 Request {
417 surface,
418 words,
419 cursor,
420 cwd: None,
421 }
422 }
423
424 #[test]
425 fn the_root_offers_the_top_level_commands() {
426 let graph = build(&Inputs::default());
427 let r = complete(
428 &graph,
429 &request(Surface::Cli, "majordomus", true),
430 &NoValues,
431 );
432 let names: Vec<&str> = r.candidates.iter().map(|c| c.value.as_str()).collect();
433 assert!(names.contains(&"worktree"), "{names:?}");
434 assert!(names.contains(&"serve"), "{names:?}");
435 }
436
437 #[test]
438 fn a_prefix_narrows_it() {
439 let graph = build(&Inputs::default());
440 let r = complete(
441 &graph,
442 &request(Surface::Cli, "majordomus work", false),
443 &NoValues,
444 );
445 assert!(r.candidates.iter().all(|c| c.value.starts_with("work")));
446 assert!(r.candidates.iter().any(|c| c.value == "worktree"));
447 }
448
449 #[test]
450 fn a_nested_command_offers_its_subcommands() {
451 let graph = build(&Inputs::default());
452 let r = complete(
453 &graph,
454 &request(Surface::Cli, "majordomus worktree", true),
455 &NoValues,
456 );
457 let names: Vec<&str> = r.candidates.iter().map(|c| c.value.as_str()).collect();
458 assert!(names.contains(&"status"), "{names:?}");
459 assert!(names.contains(&"migrate"), "{names:?}");
460 assert_eq!(r.resolved.as_deref(), Some("executable.worktree"));
461 }
462
463 #[test]
464 fn a_dash_offers_the_flags_of_the_resolved_command() {
465 let graph = build(&Inputs::default());
466 let r = complete(
467 &graph,
468 &request(Surface::Cli, "majordomus worktree status -", false),
469 &NoValues,
470 );
471 assert!(r.candidates.iter().all(|c| c.kind == CandidateKind::Flag));
472 assert!(r.candidates.iter().any(|c| c.value == "--repo"));
473 }
474
475 #[test]
476 fn a_value_enum_completes_from_the_declaration() {
477 let graph = build(&Inputs::default());
478 let node = graph
481 .commands
482 .iter()
483 .find(|n| n.arguments.iter().any(|a| !a.values.is_empty()))
484 .expect("some command has a value enum");
485 let arg = node
486 .arguments
487 .iter()
488 .find(|a| !a.values.is_empty())
489 .unwrap();
490 let long = arg.long.clone().expect("a value enum is an option here");
491 let line = format!("majordomus {} --{long}", node.path.join(" "));
492 let r = complete(&graph, &request(Surface::Cli, &line, true), &NoValues);
493 assert!(!r.candidates.is_empty(), "no values for --{long}");
494 assert!(r.candidates.iter().all(|c| c.kind == CandidateKind::Value));
495 }
496
497 #[test]
498 fn the_workflow_surface_offers_recipes_and_then_the_same_arguments() {
499 let graph = build(&Inputs::default());
500 let r = complete(&graph, &request(Surface::Workflow, "just", true), &NoValues);
501 assert!(r
502 .candidates
503 .iter()
504 .any(|c| c.value == "worktree-status" && c.kind == CandidateKind::Workflow));
505
506 let r = complete(
507 &graph,
508 &request(Surface::Workflow, "just worktree-status -", false),
509 &NoValues,
510 );
511 assert!(r.candidates.iter().any(|c| c.value == "--repo"));
513 assert_eq!(r.resolved.as_deref(), Some("executable.worktree.status"));
514 }
515
516 #[test]
517 fn a_dynamic_source_comes_from_the_resolver_and_never_from_the_engine() {
518 struct Branches;
519 impl ValueResolver for Branches {
520 fn values(&self, source: ValueSource) -> Vec<String> {
521 match source {
522 ValueSource::Branch => vec!["master".into(), "feature/x".into()],
523 _ => Vec::new(),
524 }
525 }
526 }
527 let graph = build(&Inputs::default());
528 let r = complete(
529 &graph,
530 &request(Surface::Cli, "majordomus worktree path", true),
531 &Branches,
532 );
533 let names: Vec<&str> = r.candidates.iter().map(|c| c.value.as_str()).collect();
534 assert!(names.contains(&"master"), "{names:?}");
535 let r = complete(
537 &graph,
538 &request(Surface::Cli, "majordomus worktree path", true),
539 &NoValues,
540 );
541 assert!(r.candidates.iter().all(|c| c.kind != CandidateKind::Value));
542 }
543
544 #[test]
545 fn an_unknown_command_does_not_panic() {
546 let graph = build(&Inputs::default());
547 for line in [
548 "majordomus nonesuch --unknown",
549 "majordomus worktree nonesuch",
550 "just nonesuch --x",
551 "majordomus",
552 ] {
553 let _ = complete(&graph, &request(Surface::Cli, line, true), &NoValues);
554 let _ = complete(&graph, &request(Surface::Workflow, line, false), &NoValues);
555 }
556 }
557}