Skip to main content

osp_cli/completion/
suggest.rs

1use crate::completion::context::{ProviderSelection, TreeResolver};
2use crate::completion::model::{
3    CommandLine, CompletionAnalysis, CompletionNode, CompletionTree, Suggestion, SuggestionEntry,
4    SuggestionOutput, ValueType,
5};
6use fuzzy_matcher::FuzzyMatcher;
7use fuzzy_matcher::skim::SkimMatcherV2;
8use std::collections::BTreeSet;
9use std::sync::OnceLock;
10
11const MATCH_SCORE_EXACT: u32 = 0;
12const MATCH_SCORE_EMPTY_STUB: u32 = 1_000;
13const MATCH_SCORE_PREFIX_BASE: u32 = 100;
14const MATCH_SCORE_BOUNDARY_PREFIX_BASE: u32 = 200;
15const MATCH_SCORE_FUZZY_BASE: u32 = 10_000;
16const MATCH_SCORE_FUZZY_NORMALIZED_MAX: u32 = 100_000;
17// Lower scores win:
18// exact < prefix < boundary-prefix < fuzzy fallback.
19
20struct PositionalRequest<'a> {
21    context_node: &'a CompletionNode,
22    flag_scope_node: &'a CompletionNode,
23    arg_index: usize,
24    stub: &'a str,
25    cmd: &'a CommandLine,
26    show_subcommands: bool,
27    show_flag_names: bool,
28}
29
30enum SuggestionMode<'a> {
31    Pipe,
32    FlagNames {
33        flag_scope_node: &'a CompletionNode,
34    },
35    FlagValues {
36        flag_scope_node: &'a CompletionNode,
37        flag: String,
38    },
39    Positionals {
40        context_node: &'a CompletionNode,
41        flag_scope_node: &'a CompletionNode,
42        arg_index: usize,
43        show_subcommands: bool,
44        show_flag_names: bool,
45    },
46}
47
48#[derive(Debug, Clone)]
49pub struct SuggestionEngine {
50    tree: CompletionTree,
51}
52
53impl SuggestionEngine {
54    pub fn new(tree: CompletionTree) -> Self {
55        Self { tree }
56    }
57
58    pub fn generate(&self, analysis: &CompletionAnalysis) -> Vec<SuggestionOutput> {
59        let mode = self.suggestion_mode(analysis);
60        self.emit_suggestions(mode, analysis)
61    }
62
63    fn suggestion_mode<'a>(&'a self, analysis: &'a CompletionAnalysis) -> SuggestionMode<'a> {
64        let cmd = &analysis.parsed.cursor_cmd;
65        let stub = analysis.cursor.token_stub.as_str();
66
67        if cmd.has_pipe() {
68            return SuggestionMode::Pipe;
69        }
70
71        let nodes = TreeResolver::new(&self.tree).resolved_nodes(&analysis.context);
72        if stub.starts_with('-') {
73            return SuggestionMode::FlagNames {
74                flag_scope_node: nodes.flag_scope_node,
75            };
76        }
77
78        let (needs_flag_value, last_flag) =
79            self.last_flag_needs_value(nodes.flag_scope_node, cmd, stub);
80        if needs_flag_value && let Some(flag) = last_flag {
81            return SuggestionMode::FlagValues {
82                flag_scope_node: nodes.flag_scope_node,
83                flag,
84            };
85        }
86
87        SuggestionMode::Positionals {
88            context_node: nodes.context_node,
89            flag_scope_node: nodes.flag_scope_node,
90            arg_index: self.arg_index(cmd, stub, analysis.context.matched_path.len()),
91            show_subcommands: analysis.context.subcommand_context,
92            show_flag_names: stub.is_empty() && !analysis.context.subcommand_context,
93        }
94    }
95
96    fn emit_suggestions(
97        &self,
98        mode: SuggestionMode<'_>,
99        analysis: &CompletionAnalysis,
100    ) -> Vec<SuggestionOutput> {
101        let cmd = &analysis.parsed.cursor_cmd;
102        let stub = analysis.cursor.token_stub.as_str();
103
104        let mut out = match mode {
105            SuggestionMode::Pipe => self.pipe_suggestions(stub),
106            SuggestionMode::FlagNames { flag_scope_node } => self
107                .flag_name_suggestions(flag_scope_node, stub, cmd)
108                .into_iter()
109                .map(SuggestionOutput::Item)
110                .collect(),
111            SuggestionMode::FlagValues {
112                flag_scope_node,
113                flag,
114            } => self.flag_value_suggestions(flag_scope_node, &flag, stub, cmd),
115            SuggestionMode::Positionals {
116                context_node,
117                flag_scope_node,
118                arg_index,
119                show_subcommands,
120                show_flag_names,
121            } => {
122                let request = PositionalRequest {
123                    context_node,
124                    flag_scope_node,
125                    arg_index,
126                    stub,
127                    cmd,
128                    show_subcommands,
129                    show_flag_names,
130                };
131                let mut out = self.positional_suggestions(request);
132                sort_suggestion_outputs(&mut out);
133                return out;
134            }
135        };
136
137        sort_suggestion_outputs(&mut out);
138        out
139    }
140
141    fn positional_suggestions(&self, request: PositionalRequest<'_>) -> Vec<SuggestionOutput> {
142        let mut out = Vec::new();
143
144        if request.show_subcommands {
145            out.extend(
146                self.subcommand_suggestions(request.context_node, request.stub)
147                    .into_iter()
148                    .map(SuggestionOutput::Item),
149            );
150        } else {
151            out.extend(self.arg_value_suggestions(
152                request.context_node,
153                request.arg_index,
154                request.stub,
155            ));
156        }
157
158        if request.show_flag_names {
159            out.extend(
160                self.flag_name_suggestions(request.flag_scope_node, request.stub, request.cmd)
161                    .into_iter()
162                    .filter(|suggestion| !request.cmd.has_flag(&suggestion.text))
163                    .map(SuggestionOutput::Item),
164            );
165        }
166
167        out
168    }
169
170    fn pipe_suggestions(&self, stub: &str) -> Vec<SuggestionOutput> {
171        self.tree
172            .pipe_verbs
173            .iter()
174            .filter_map(|(verb, tooltip)| {
175                let score = self.match_score(stub, verb)?;
176                Some(SuggestionOutput::Item(Suggestion {
177                    text: verb.clone(),
178                    meta: Some(tooltip.clone()),
179                    display: None,
180                    is_exact: score == 0,
181                    sort: None,
182                    match_score: score,
183                }))
184            })
185            .collect()
186    }
187
188    fn flag_name_suggestions(
189        &self,
190        node: &CompletionNode,
191        stub: &str,
192        cmd: &CommandLine,
193    ) -> Vec<Suggestion> {
194        let allowlist = self.resolved_flag_allowlist(node, cmd);
195        let required = self.required_flags(node, cmd);
196
197        node.flags
198            .iter()
199            .filter_map(|(flag, meta)| {
200                let score = self.match_score(stub, flag)?;
201                Some((flag, meta, score))
202            })
203            .filter(|(flag, _, _)| {
204                allowlist
205                    .as_ref()
206                    .is_none_or(|allowed| allowed.contains(flag.as_str()))
207            })
208            .filter(|(flag, _, _)| !cmd.has_flag(flag) || stub == *flag)
209            .map(|(flag, meta, score)| Suggestion {
210                text: flag.clone(),
211                meta: meta.tooltip.clone(),
212                display: required.contains(flag.as_str()).then(|| format!("{flag}*")),
213                is_exact: score == 0,
214                sort: None,
215                match_score: score,
216            })
217            .collect()
218    }
219
220    fn flag_value_suggestions(
221        &self,
222        node: &CompletionNode,
223        flag: &str,
224        stub: &str,
225        cmd: &CommandLine,
226    ) -> Vec<SuggestionOutput> {
227        let Some(flag_node) = node.flags.get(flag) else {
228            return Vec::new();
229        };
230
231        if flag_node.flag_only {
232            return Vec::new();
233        }
234
235        if flag_node.value_type == Some(ValueType::Path) {
236            return vec![SuggestionOutput::PathSentinel];
237        }
238
239        if let Some(output) =
240            self.provider_specific_flag_value_suggestions(flag_node, flag, stub, cmd)
241        {
242            return output;
243        }
244
245        self.entry_suggestions(&flag_node.suggestions, stub)
246    }
247
248    fn arg_value_suggestions(
249        &self,
250        node: &CompletionNode,
251        index: usize,
252        stub: &str,
253    ) -> Vec<SuggestionOutput> {
254        let Some(arg) = node.args.get(index) else {
255            return Vec::new();
256        };
257
258        if arg.value_type == Some(ValueType::Path) {
259            return vec![SuggestionOutput::PathSentinel];
260        }
261
262        self.entry_suggestions(&arg.suggestions, stub)
263    }
264
265    fn subcommand_suggestions(&self, node: &CompletionNode, stub: &str) -> Vec<Suggestion> {
266        node.children
267            .iter()
268            .filter_map(|(name, child)| {
269                let score = self.match_score(stub, name)?;
270                Some(Suggestion {
271                    text: name.clone(),
272                    meta: child_completion_meta(child),
273                    display: None,
274                    is_exact: score == 0,
275                    sort: child.sort.clone(),
276                    match_score: score,
277                })
278            })
279            .collect()
280    }
281
282    fn last_flag_needs_value(
283        &self,
284        node: &CompletionNode,
285        cmd: &CommandLine,
286        stub: &str,
287    ) -> (bool, Option<String>) {
288        let Some(last_occurrence) = cmd.last_flag_occurrence() else {
289            return (false, None);
290        };
291        let last_flag = &last_occurrence.name;
292
293        let Some(flag_node) = node.flags.get(last_flag) else {
294            return (false, None);
295        };
296
297        if flag_node.flag_only {
298            return (false, None);
299        }
300
301        if last_occurrence.values.is_empty() {
302            return (true, Some(last_flag.clone()));
303        }
304
305        if !stub.is_empty()
306            && last_occurrence.values.last().is_some_and(|value| {
307                value
308                    .to_ascii_lowercase()
309                    .starts_with(&stub.to_ascii_lowercase())
310            })
311        {
312            return (true, Some(last_flag.clone()));
313        }
314
315        (flag_node.multi, Some(last_flag.clone()))
316    }
317
318    fn arg_index(&self, cmd: &CommandLine, stub: &str, matched_head_len: usize) -> usize {
319        cmd.head()
320            .iter()
321            .skip(matched_head_len)
322            .chain(cmd.positional_args())
323            .filter(|token| token.as_str() != stub)
324            .count()
325    }
326
327    fn provider_specific_flag_value_suggestions(
328        &self,
329        flag_node: &crate::completion::model::FlagNode,
330        flag: &str,
331        stub: &str,
332        cmd: &CommandLine,
333    ) -> Option<Vec<SuggestionOutput>> {
334        // Provider completion has two special cases:
335        // - selecting `--provider` may be constrained by the current `--os`
336        // - many flags expose provider-specific value sets once a provider is chosen
337        //
338        // `osp-cli` marks these selector flags as context-only in
339        // `repl/completion.rs`; the suggestion engine still needs a small
340        // amount of flag-name-specific logic until that relationship is fully
341        // expressed in completion metadata.
342        let provider = ProviderSelection::from_command(cmd);
343
344        if flag == "--provider" {
345            let os_token = provider.normalized_os();
346            if let Some(os_token) = os_token {
347                let filtered = flag_node
348                    .suggestions
349                    .iter()
350                    .filter(|entry| {
351                        flag_node
352                            .os_provider_map
353                            .get(os_token)
354                            .is_none_or(|providers| providers.iter().any(|p| p == &entry.value))
355                    })
356                    .cloned()
357                    .collect::<Vec<_>>();
358                if !filtered.is_empty() {
359                    return Some(self.entry_suggestions(&filtered, stub));
360                }
361            }
362        }
363
364        let provider_values = flag_node.suggestions_by_provider.get(provider.name()?)?;
365        Some(self.entry_suggestions(provider_values, stub))
366    }
367
368    fn entry_suggestions(&self, entries: &[SuggestionEntry], stub: &str) -> Vec<SuggestionOutput> {
369        entries
370            .iter()
371            .filter_map(|entry| {
372                let score = self.match_score(stub, &entry.value)?;
373                Some(SuggestionOutput::Item(entry_to_suggestion(entry, score)))
374            })
375            .collect()
376    }
377
378    fn match_score(&self, stub: &str, candidate: &str) -> Option<u32> {
379        // Lower scores are better:
380        // - exact match wins with 0
381        // - 100-range keeps ordinary prefix matches together
382        // - 200-range keeps word-boundary prefixes behind direct prefixes
383        // - 10_000+ is fuzzy fallback, where higher fuzzy scores reduce the
384        //   penalty and therefore sort earlier
385        if stub.is_empty() {
386            return Some(MATCH_SCORE_EMPTY_STUB);
387        }
388
389        let stub_lc = stub.to_ascii_lowercase();
390        let candidate_lc = candidate.to_ascii_lowercase();
391
392        if stub_lc == candidate_lc {
393            return Some(MATCH_SCORE_EXACT);
394        }
395        if candidate_lc.starts_with(&stub_lc) {
396            return Some(MATCH_SCORE_PREFIX_BASE + (candidate_lc.len() - stub_lc.len()) as u32);
397        }
398
399        if let Some(boundary) = boundary_prefix_index(&candidate_lc, &stub_lc) {
400            return Some(MATCH_SCORE_BOUNDARY_PREFIX_BASE + boundary as u32);
401        }
402
403        let fuzzy = fuzzy_matcher().fuzzy_match(&candidate_lc, &stub_lc)?;
404        let normalized = fuzzy.max(0) as u32;
405        let penalty = MATCH_SCORE_FUZZY_NORMALIZED_MAX.saturating_sub(normalized);
406        Some(MATCH_SCORE_FUZZY_BASE + penalty)
407    }
408
409    fn resolved_flag_allowlist(
410        &self,
411        node: &CompletionNode,
412        cmd: &CommandLine,
413    ) -> Option<BTreeSet<String>> {
414        let hints = node.flag_hints.as_ref()?;
415        let mut allowed = hints.common.iter().cloned().collect::<BTreeSet<_>>();
416
417        if let Some(provider) = ProviderSelection::from_command(cmd).name() {
418            if let Some(provider_specific) = hints.by_provider.get(provider) {
419                allowed.extend(provider_specific.iter().cloned());
420            }
421            // Once provider is selected, hide selector flags.
422            allowed.remove("--provider");
423            allowed.remove("--nrec");
424            allowed.remove("--vmware");
425        }
426
427        if cmd.has_flag("--linux") {
428            allowed.remove("--windows");
429        }
430        if cmd.has_flag("--windows") {
431            allowed.remove("--linux");
432        }
433
434        Some(allowed)
435    }
436
437    fn required_flags(&self, node: &CompletionNode, cmd: &CommandLine) -> BTreeSet<String> {
438        let mut required = BTreeSet::new();
439        let Some(hints) = node.flag_hints.as_ref() else {
440            return required;
441        };
442
443        required.extend(hints.required_common.iter().cloned());
444        if let Some(provider) = ProviderSelection::from_command(cmd).name()
445            && let Some(provider_required) = hints.required_by_provider.get(provider)
446        {
447            required.extend(provider_required.iter().cloned());
448        }
449        required
450    }
451}
452
453fn child_completion_meta(child: &CompletionNode) -> Option<String> {
454    let summary = child_subcommand_summary(child);
455    match (child.tooltip.as_deref(), summary) {
456        (Some(tooltip), Some(summary)) => Some(format!("{tooltip} ({summary})")),
457        (Some(tooltip), None) => Some(tooltip.to_string()),
458        (None, Some(summary)) => Some(summary),
459        (None, None) => None,
460    }
461}
462
463fn child_subcommand_summary(child: &CompletionNode) -> Option<String> {
464    if child.children.is_empty() {
465        return None;
466    }
467
468    let preview = child.children.keys().take(3).cloned().collect::<Vec<_>>();
469    if preview.is_empty() {
470        return None;
471    }
472
473    let mut summary = format!("subcommands: {}", preview.join(", "));
474    if child.children.len() > preview.len() {
475        summary.push_str(", ...");
476    }
477    Some(summary)
478}
479
480fn sort_suggestion_outputs(outputs: &mut Vec<SuggestionOutput>) {
481    let mut items: Vec<Suggestion> = outputs
482        .iter()
483        .filter_map(|entry| match entry {
484            SuggestionOutput::Item(item) => Some(item.clone()),
485            SuggestionOutput::PathSentinel => None,
486        })
487        .collect();
488    let path_sentinel_count = outputs
489        .iter()
490        .filter(|entry| matches!(entry, SuggestionOutput::PathSentinel))
491        .count();
492
493    items.sort_by(compare_suggestions);
494
495    // Path sentinels are not ranked suggestions; they are control markers that
496    // tell the caller to ask the shell for filesystem completion after all
497    // normal ranked suggestions have been shown.
498    outputs.clear();
499    outputs.extend(items.into_iter().map(SuggestionOutput::Item));
500    outputs.extend(std::iter::repeat_n(
501        SuggestionOutput::PathSentinel,
502        path_sentinel_count,
503    ));
504}
505
506fn compare_suggestions(left: &Suggestion, right: &Suggestion) -> std::cmp::Ordering {
507    (not_exact(left), left.match_score)
508        .cmp(&(not_exact(right), right.match_score))
509        .then_with(|| compare_sort_value(left.sort.as_deref(), right.sort.as_deref()))
510        .then_with(|| {
511            left.text
512                .to_ascii_lowercase()
513                .cmp(&right.text.to_ascii_lowercase())
514        })
515}
516
517fn compare_sort_value(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
518    match (left, right) {
519        (Some(left), Some(right)) => {
520            match (
521                left.trim().parse::<f64>().ok(),
522                right.trim().parse::<f64>().ok(),
523            ) {
524                (Some(left_num), Some(right_num)) => left_num
525                    .partial_cmp(&right_num)
526                    .unwrap_or(std::cmp::Ordering::Equal),
527                _ => left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase()),
528            }
529        }
530        (Some(_), None) => std::cmp::Ordering::Less,
531        (None, Some(_)) => std::cmp::Ordering::Greater,
532        (None, None) => std::cmp::Ordering::Equal,
533    }
534}
535
536fn not_exact(suggestion: &Suggestion) -> bool {
537    !suggestion.is_exact
538}
539
540fn entry_to_suggestion(entry: &SuggestionEntry, match_score: u32) -> Suggestion {
541    Suggestion {
542        text: entry.value.clone(),
543        meta: entry.meta.clone(),
544        display: entry.display.clone(),
545        is_exact: match_score == 0,
546        sort: entry.sort.clone(),
547        match_score,
548    }
549}
550
551fn boundary_prefix_index(candidate: &str, stub: &str) -> Option<usize> {
552    candidate
553        .match_indices(stub)
554        .find(|(idx, _)| {
555            *idx == 0
556                || candidate
557                    .as_bytes()
558                    .get(idx.saturating_sub(1))
559                    .is_some_and(|byte| matches!(byte, b'-' | b'_' | b'.' | b':' | b'/'))
560        })
561        .map(|(idx, _)| idx)
562}
563
564fn fuzzy_matcher() -> &'static SkimMatcherV2 {
565    static MATCHER: OnceLock<SkimMatcherV2> = OnceLock::new();
566    MATCHER.get_or_init(SkimMatcherV2::default)
567}
568
569#[cfg(test)]
570mod tests {
571    use std::collections::BTreeMap;
572
573    use crate::completion::model::{
574        ArgNode, CommandLine, CompletionNode, CompletionTree, CursorState, FlagHints, FlagNode,
575        FlagOccurrence, SuggestionEntry, SuggestionOutput, ValueType,
576    };
577
578    use crate::completion::CompletionEngine;
579
580    fn tree() -> CompletionTree {
581        let mut provision = CompletionNode::default();
582        provision.flags.insert(
583            "--provider".to_string(),
584            FlagNode {
585                suggestions: vec![
586                    SuggestionEntry::from("nrec"),
587                    SuggestionEntry::from("vmware"),
588                ],
589                os_provider_map: BTreeMap::from([
590                    ("alma".to_string(), vec!["nrec".to_string()]),
591                    ("rhel".to_string(), vec!["vmware".to_string()]),
592                ]),
593                ..FlagNode::default()
594            },
595        );
596        provision.flags.insert(
597            "--os".to_string(),
598            FlagNode {
599                suggestions: vec![SuggestionEntry::from("alma"), SuggestionEntry::from("rhel")],
600                ..FlagNode::default()
601            },
602        );
603
604        let mut orch = CompletionNode::default();
605        orch.children.insert("provision".to_string(), provision);
606
607        CompletionTree {
608            root: CompletionNode::default().with_child("orch", orch),
609            pipe_verbs: BTreeMap::from([("F".to_string(), "Filter".to_string())]),
610        }
611    }
612
613    fn values(output: Vec<SuggestionOutput>) -> Vec<String> {
614        output
615            .into_iter()
616            .filter_map(|entry| match entry {
617                SuggestionOutput::Item(item) => Some(item.text),
618                SuggestionOutput::PathSentinel => None,
619            })
620            .collect()
621    }
622
623    fn generate(engine: &CompletionEngine, cmd: CommandLine, stub: &str) -> Vec<SuggestionOutput> {
624        let analysis = engine.analyze_command(cmd.clone(), cmd, CursorState::synthetic(stub));
625        engine.suggestions_for_analysis(&analysis)
626    }
627
628    fn values_for_line(engine: &CompletionEngine, line: &str) -> Vec<String> {
629        let (_, output) = engine.complete(line, line.len());
630        values(output)
631    }
632
633    fn command(head: &[&str]) -> CommandLine {
634        let mut cmd = CommandLine::default();
635        for segment in head {
636            cmd.push_head(*segment);
637        }
638        cmd
639    }
640
641    fn with_flag(mut cmd: CommandLine, name: &str, values: &[&str]) -> CommandLine {
642        cmd.push_flag_occurrence(FlagOccurrence {
643            name: name.to_string(),
644            values: values.iter().map(|value| (*value).to_string()).collect(),
645        });
646        cmd
647    }
648
649    #[test]
650    fn suggests_flags_in_scope() {
651        let engine = CompletionEngine::new(tree());
652        let cmd = command(&["orch", "provision"]);
653
654        let values = values(generate(&engine, cmd, "--"));
655        assert!(values.contains(&"--provider".to_string()));
656        assert!(values.contains(&"--os".to_string()));
657    }
658
659    #[test]
660    fn fuzzy_matches_flag_names() {
661        let engine = CompletionEngine::new(tree());
662        let cmd = command(&["orch", "provision"]);
663
664        let values = values(generate(&engine, cmd, "--prv"));
665        assert!(values.contains(&"--provider".to_string()));
666    }
667
668    #[test]
669    fn suggests_flag_values() {
670        let engine = CompletionEngine::new(tree());
671        let cmd = with_flag(command(&["orch", "provision"]), "--provider", &[]);
672
673        let values = values(generate(&engine, cmd, ""));
674
675        assert!(values.contains(&"nrec".to_string()));
676        assert!(values.contains(&"vmware".to_string()));
677    }
678
679    #[test]
680    fn filters_provider_values_by_os() {
681        let engine = CompletionEngine::new(tree());
682        let cmd = with_flag(
683            with_flag(command(&["orch", "provision"]), "--os", &["alma"]),
684            "--provider",
685            &[],
686        );
687
688        let values = values(generate(&engine, cmd, ""));
689
690        assert!(values.contains(&"nrec".to_string()));
691        assert!(!values.contains(&"vmware".to_string()));
692    }
693
694    #[test]
695    fn suggests_pipe_verbs_after_pipe() {
696        let engine = CompletionEngine::new(tree());
697        let mut cmd = CommandLine::default();
698        cmd.set_pipe(Vec::new());
699
700        let output = generate(&engine, cmd, "F");
701        assert!(
702            output
703                .iter()
704                .any(|entry| matches!(entry, SuggestionOutput::Item(item) if item.text == "F"))
705        );
706    }
707
708    #[test]
709    fn fuzzy_matches_long_pipe_verbs() {
710        let mut tree = tree();
711        tree.pipe_verbs
712            .insert("VALUE".to_string(), "Extract values".to_string());
713        tree.pipe_verbs
714            .insert("VAL".to_string(), "Extract".to_string());
715        let engine = CompletionEngine::new(tree);
716        let mut cmd = CommandLine::default();
717        cmd.set_pipe(Vec::new());
718
719        let output = generate(&engine, cmd, "vlu");
720        assert!(
721            output
722                .iter()
723                .any(|entry| matches!(entry, SuggestionOutput::Item(item) if item.text == "VALUE"))
724        );
725        let values = values(output);
726        assert_eq!(values.first().map(String::as_str), Some("VALUE"));
727    }
728
729    #[test]
730    fn single_value_flag_switches_to_other_flags_after_value() {
731        let mut cmd_node = CompletionNode::default();
732        cmd_node.flags.insert(
733            "--context".to_string(),
734            FlagNode {
735                suggestions: vec![
736                    SuggestionEntry::from("uio"),
737                    SuggestionEntry::from("tsd"),
738                    SuggestionEntry::from("edu"),
739                ],
740                ..FlagNode::default()
741            },
742        );
743        cmd_node.flags.insert(
744            "--terminal".to_string(),
745            FlagNode {
746                suggestions: vec![SuggestionEntry::from("cli"), SuggestionEntry::from("repl")],
747                ..FlagNode::default()
748            },
749        );
750
751        let tree = CompletionTree {
752            root: CompletionNode::default().with_child("alias", cmd_node),
753            ..CompletionTree::default()
754        };
755        let engine = CompletionEngine::new(tree);
756
757        let cmd = with_flag(command(&["alias"]), "--context", &["uio"]);
758        let values = values(generate(&engine, cmd, ""));
759        assert!(!values.contains(&"uio".to_string()));
760        assert!(values.contains(&"--terminal".to_string()));
761    }
762
763    #[test]
764    fn multi_value_flag_stays_in_value_mode_until_dash() {
765        let mut cmd_node = CompletionNode::default();
766        cmd_node.flags.insert(
767            "--tags".to_string(),
768            FlagNode {
769                multi: true,
770                suggestions: vec![
771                    SuggestionEntry::from("red"),
772                    SuggestionEntry::from("green"),
773                    SuggestionEntry::from("blue"),
774                ],
775                ..FlagNode::default()
776            },
777        );
778        cmd_node.flags.insert(
779            "--mode".to_string(),
780            FlagNode {
781                suggestions: vec![SuggestionEntry::from("fast"), SuggestionEntry::from("full")],
782                ..FlagNode::default()
783            },
784        );
785
786        let tree = CompletionTree {
787            root: CompletionNode::default().with_child("tag", cmd_node),
788            ..CompletionTree::default()
789        };
790        let engine = CompletionEngine::new(tree);
791
792        let cmd = with_flag(command(&["tag"]), "--tags", &["red"]);
793        let values_for_space = values(generate(&engine, cmd.clone(), ""));
794        assert!(values_for_space.contains(&"red".to_string()));
795        assert!(!values_for_space.contains(&"--mode".to_string()));
796
797        let values_for_dash = values(generate(&engine, cmd, "-"));
798        assert!(values_for_dash.contains(&"--mode".to_string()));
799    }
800
801    #[test]
802    fn repeated_flag_without_value_stays_in_value_mode_for_last_occurrence() {
803        let mut cmd_node = CompletionNode::default();
804        cmd_node.flags.insert(
805            "--tags".to_string(),
806            FlagNode {
807                multi: true,
808                suggestions: vec![
809                    SuggestionEntry::from("red"),
810                    SuggestionEntry::from("green"),
811                    SuggestionEntry::from("blue"),
812                ],
813                ..FlagNode::default()
814            },
815        );
816        cmd_node.flags.insert(
817            "--mode".to_string(),
818            FlagNode {
819                suggestions: vec![SuggestionEntry::from("fast"), SuggestionEntry::from("full")],
820                ..FlagNode::default()
821            },
822        );
823
824        let tree = CompletionTree {
825            root: CompletionNode::default().with_child("tag", cmd_node),
826            ..CompletionTree::default()
827        };
828        let engine = CompletionEngine::new(tree);
829
830        let values = values_for_line(&engine, "tag --tags red --mode fast --tags ");
831        assert!(values.contains(&"red".to_string()));
832        assert!(values.contains(&"green".to_string()));
833        assert!(!values.contains(&"--mode".to_string()));
834    }
835
836    #[test]
837    fn repeated_flag_partial_value_uses_last_occurrence_context() {
838        let mut cmd_node = CompletionNode::default();
839        cmd_node.flags.insert(
840            "--tags".to_string(),
841            FlagNode {
842                multi: true,
843                suggestions: vec![
844                    SuggestionEntry::from("red"),
845                    SuggestionEntry::from("green"),
846                    SuggestionEntry::from("blue"),
847                ],
848                ..FlagNode::default()
849            },
850        );
851
852        let tree = CompletionTree {
853            root: CompletionNode::default().with_child("tag", cmd_node),
854            ..CompletionTree::default()
855        };
856        let engine = CompletionEngine::new(tree);
857
858        let values = values_for_line(&engine, "tag --tags red --tags bl");
859        assert!(values.contains(&"blue".to_string()));
860    }
861
862    #[test]
863    fn args_after_double_dash_advance_index() {
864        let cmd_node = CompletionNode {
865            args: vec![
866                ArgNode {
867                    suggestions: vec![SuggestionEntry::from("one")],
868                    ..ArgNode::default()
869                },
870                ArgNode {
871                    suggestions: vec![SuggestionEntry::from("two"), SuggestionEntry::from("three")],
872                    ..ArgNode::default()
873                },
874            ],
875            ..CompletionNode::default()
876        };
877        let tree = CompletionTree {
878            root: CompletionNode::default().with_child("cmd", cmd_node),
879            ..CompletionTree::default()
880        };
881        let engine = CompletionEngine::new(tree);
882        let mut cmd = command(&["cmd"]);
883        cmd.push_positional("one");
884
885        let values = values(generate(&engine, cmd, ""));
886        assert!(values.contains(&"two".to_string()));
887        assert!(values.contains(&"three".to_string()));
888        assert!(!values.contains(&"one".to_string()));
889    }
890
891    #[test]
892    fn path_arg_emits_path_sentinel() {
893        let cmd_node = CompletionNode {
894            args: vec![ArgNode {
895                value_type: Some(ValueType::Path),
896                ..ArgNode::default()
897            }],
898            ..CompletionNode::default()
899        };
900        let tree = CompletionTree {
901            root: CompletionNode::default().with_child("cmd", cmd_node),
902            ..CompletionTree::default()
903        };
904        let engine = CompletionEngine::new(tree);
905        let cmd = command(&["cmd"]);
906
907        let output = generate(&engine, cmd, "");
908        assert!(
909            output
910                .iter()
911                .any(|entry| matches!(entry, SuggestionOutput::PathSentinel))
912        );
913    }
914
915    #[test]
916    fn flag_hints_filter_provider_specific_flags_and_hide_selectors() {
917        let mut node = CompletionNode::default();
918        node.flags
919            .insert("--provider".to_string(), FlagNode::default());
920        node.flags.insert(
921            "--nrec".to_string(),
922            FlagNode {
923                flag_only: true,
924                ..FlagNode::default()
925            },
926        );
927        node.flags.insert(
928            "--vmware".to_string(),
929            FlagNode {
930                flag_only: true,
931                ..FlagNode::default()
932            },
933        );
934        node.flags
935            .insert("--comment".to_string(), FlagNode::default());
936        node.flags
937            .insert("--flavor".to_string(), FlagNode::default());
938        node.flags
939            .insert("--vcenter".to_string(), FlagNode::default());
940        node.flag_hints = Some(FlagHints {
941            common: vec![
942                "--provider".to_string(),
943                "--nrec".to_string(),
944                "--vmware".to_string(),
945                "--comment".to_string(),
946            ],
947            by_provider: BTreeMap::from([
948                ("nrec".to_string(), vec!["--flavor".to_string()]),
949                ("vmware".to_string(), vec!["--vcenter".to_string()]),
950            ]),
951            required_common: vec!["--comment".to_string()],
952            required_by_provider: BTreeMap::from([(
953                "nrec".to_string(),
954                vec!["--flavor".to_string()],
955            )]),
956        });
957
958        let tree = CompletionTree {
959            root: CompletionNode::default().with_child("provision", node),
960            ..CompletionTree::default()
961        };
962        let engine = CompletionEngine::new(tree);
963
964        let cmd = with_flag(command(&["provision"]), "--provider", &["nrec"]);
965        let output = generate(&engine, cmd, "--");
966        let values = values(output.clone());
967        assert!(values.contains(&"--comment".to_string()));
968        assert!(values.contains(&"--flavor".to_string()));
969        assert!(!values.contains(&"--provider".to_string()));
970        assert!(!values.contains(&"--nrec".to_string()));
971        assert!(!values.contains(&"--vmware".to_string()));
972        assert!(!values.contains(&"--vcenter".to_string()));
973
974        let items = output
975            .into_iter()
976            .filter_map(|entry| match entry {
977                SuggestionOutput::Item(item) => Some(item),
978                SuggestionOutput::PathSentinel => None,
979            })
980            .collect::<Vec<_>>();
981        let by_text = items
982            .into_iter()
983            .map(|item| (item.text.clone(), item))
984            .collect::<BTreeMap<_, _>>();
985        assert_eq!(
986            by_text
987                .get("--comment")
988                .and_then(|item| item.display.as_deref()),
989            Some("--comment*")
990        );
991        assert_eq!(
992            by_text
993                .get("--flavor")
994                .and_then(|item| item.display.as_deref()),
995            Some("--flavor*")
996        );
997    }
998
999    #[test]
1000    fn provider_alias_flag_enables_provider_specific_allowlist() {
1001        let mut node = CompletionNode::default();
1002        node.flags
1003            .insert("--provider".to_string(), FlagNode::default());
1004        node.flags.insert(
1005            "--nrec".to_string(),
1006            FlagNode {
1007                flag_only: true,
1008                ..FlagNode::default()
1009            },
1010        );
1011        node.flags
1012            .insert("--flavor".to_string(), FlagNode::default());
1013        node.flag_hints = Some(FlagHints {
1014            common: vec!["--provider".to_string(), "--nrec".to_string()],
1015            by_provider: BTreeMap::from([("nrec".to_string(), vec!["--flavor".to_string()])]),
1016            ..FlagHints::default()
1017        });
1018
1019        let tree = CompletionTree {
1020            root: CompletionNode::default().with_child("provision", node),
1021            ..CompletionTree::default()
1022        };
1023        let engine = CompletionEngine::new(tree);
1024        let cmd = with_flag(command(&["provision"]), "--nrec", &[]);
1025
1026        let values = values(generate(&engine, cmd, "--"));
1027        assert!(values.contains(&"--flavor".to_string()));
1028        assert!(!values.contains(&"--provider".to_string()));
1029    }
1030
1031    #[test]
1032    fn path_flag_emits_path_sentinel() {
1033        let mut node = CompletionNode::default();
1034        node.flags.insert(
1035            "--file".to_string(),
1036            FlagNode {
1037                value_type: Some(ValueType::Path),
1038                ..FlagNode::default()
1039            },
1040        );
1041
1042        let tree = CompletionTree {
1043            root: CompletionNode::default().with_child("cmd", node),
1044            ..CompletionTree::default()
1045        };
1046        let engine = CompletionEngine::new(tree);
1047        let cmd = with_flag(command(&["cmd"]), "--file", &[]);
1048
1049        let output = generate(&engine, cmd, "");
1050        assert!(
1051            output
1052                .iter()
1053                .any(|entry| matches!(entry, SuggestionOutput::PathSentinel))
1054        );
1055    }
1056
1057    #[test]
1058    fn flag_suggestions_preserve_meta_and_display_fields() {
1059        let mut node = CompletionNode::default();
1060        node.flags.insert(
1061            "--flavor".to_string(),
1062            FlagNode {
1063                suggestions: vec![
1064                    SuggestionEntry {
1065                        value: "m1.small".to_string(),
1066                        meta: Some("1 vCPU".to_string()),
1067                        display: Some("small".to_string()),
1068                        sort: Some("10".to_string()),
1069                    },
1070                    SuggestionEntry::from("m1.medium"),
1071                ],
1072                ..FlagNode::default()
1073            },
1074        );
1075        let tree = CompletionTree {
1076            root: CompletionNode::default().with_child("orch", node),
1077            ..CompletionTree::default()
1078        };
1079        let engine = CompletionEngine::new(tree);
1080        let cmd = with_flag(command(&["orch"]), "--flavor", &[]);
1081
1082        let output = generate(&engine, cmd, "");
1083        let items = output
1084            .into_iter()
1085            .filter_map(|entry| match entry {
1086                SuggestionOutput::Item(item) => Some(item),
1087                SuggestionOutput::PathSentinel => None,
1088            })
1089            .collect::<Vec<_>>();
1090
1091        let rich = items
1092            .iter()
1093            .find(|item| item.text == "m1.small")
1094            .expect("m1.small suggestion should exist");
1095        assert_eq!(rich.meta.as_deref(), Some("1 vCPU"));
1096        assert_eq!(rich.display.as_deref(), Some("small"));
1097        assert_eq!(rich.sort.as_deref(), Some("10"));
1098    }
1099
1100    #[test]
1101    fn arg_suggestions_honor_numeric_sort_after_match_score() {
1102        let cmd_node = CompletionNode {
1103            args: vec![ArgNode {
1104                suggestions: vec![
1105                    SuggestionEntry {
1106                        value: "v10".to_string(),
1107                        meta: None,
1108                        display: None,
1109                        sort: Some("10".to_string()),
1110                    },
1111                    SuggestionEntry {
1112                        value: "v2".to_string(),
1113                        meta: None,
1114                        display: None,
1115                        sort: Some("2".to_string()),
1116                    },
1117                ],
1118                ..ArgNode::default()
1119            }],
1120            ..CompletionNode::default()
1121        };
1122        let tree = CompletionTree {
1123            root: CompletionNode::default().with_child("cmd", cmd_node),
1124            ..CompletionTree::default()
1125        };
1126        let engine = CompletionEngine::new(tree);
1127        let cmd = command(&["cmd"]);
1128
1129        let values = values(generate(&engine, cmd, ""));
1130        assert_eq!(values, vec!["v2".to_string(), "v10".to_string()]);
1131    }
1132
1133    #[test]
1134    fn subcommand_suggestions_honor_child_sort_after_match_score() {
1135        let tree = CompletionTree {
1136            root: CompletionNode::default()
1137                .with_child("orch", CompletionNode::default().sort("20"))
1138                .with_child("config", CompletionNode::default().sort("10")),
1139            ..CompletionTree::default()
1140        };
1141        let engine = CompletionEngine::new(tree);
1142
1143        let output = values(generate(&engine, CommandLine::default(), ""));
1144
1145        assert_eq!(output[..2], ["config", "orch"]);
1146    }
1147}