Skip to main content

osp_cli/completion/
tree.rs

1//! Declarative builders for the completion engine's immutable tree model.
2//!
3//! Public API shape:
4//!
5//! - describe command surfaces with [`CommandSpec`]
6//! - lower them into a cached [`crate::completion::CompletionTree`] with
7//!   [`CompletionTreeBuilder`]
8//! - keep the resulting tree as plain data so the engine and embedders can
9//!   reuse it without retaining builder state
10
11use std::collections::BTreeMap;
12
13use crate::completion::model::{
14    ArgNode, CompletionNode, CompletionTree, FlagNode, SuggestionEntry,
15};
16use crate::core::command_def::{ArgDef, CommandDef, FlagDef, ValueChoice, ValueKind};
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19/// Declarative command description used to build a completion tree.
20pub struct CommandSpec {
21    /// Command or subcommand name.
22    pub name: String,
23    /// Optional description shown alongside the command.
24    pub tooltip: Option<String>,
25    /// Optional hidden sort key for display ordering.
26    pub sort: Option<String>,
27    /// Positional arguments accepted by this command.
28    pub args: Vec<ArgNode>,
29    /// Flags accepted by this command.
30    pub flags: BTreeMap<String, FlagNode>,
31    /// Nested subcommands below this command.
32    pub subcommands: Vec<CommandSpec>,
33}
34
35impl CommandSpec {
36    /// Starts a declarative command spec with the given command name.
37    pub fn new(name: impl Into<String>) -> Self {
38        Self {
39            name: name.into(),
40            ..Self::default()
41        }
42    }
43
44    /// Attaches the description shown alongside this command in completion UIs.
45    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
46        self.tooltip = Some(tooltip.into());
47        self
48    }
49
50    /// Attaches a hidden sort key used to stabilize menu ordering.
51    pub fn sort(mut self, sort: impl Into<String>) -> Self {
52        self.sort = Some(sort.into());
53        self
54    }
55
56    /// Appends one positional argument definition.
57    pub fn arg(mut self, arg: ArgNode) -> Self {
58        self.args.push(arg);
59        self
60    }
61
62    /// Extends the command with positional argument definitions.
63    pub fn args(mut self, args: impl IntoIterator<Item = ArgNode>) -> Self {
64        self.args.extend(args);
65        self
66    }
67
68    /// Adds one flag definition keyed by its spelling.
69    pub fn flag(mut self, name: impl Into<String>, flag: FlagNode) -> Self {
70        self.flags.insert(name.into(), flag);
71        self
72    }
73
74    /// Extends the command with multiple flag definitions.
75    pub fn flags(mut self, flags: impl IntoIterator<Item = (String, FlagNode)>) -> Self {
76        self.flags.extend(flags);
77        self
78    }
79
80    /// Appends one nested subcommand.
81    pub fn subcommand(mut self, subcommand: CommandSpec) -> Self {
82        self.subcommands.push(subcommand);
83        self
84    }
85
86    /// Extends the command with nested subcommands.
87    pub fn subcommands(mut self, subcommands: impl IntoIterator<Item = CommandSpec>) -> Self {
88        self.subcommands.extend(subcommands);
89        self
90    }
91}
92
93#[derive(Debug, Clone, Default)]
94/// Builds immutable completion trees from command and config metadata.
95///
96/// This is the canonical builder surface for completion-tree construction.
97pub struct CompletionTreeBuilder;
98
99impl CompletionTreeBuilder {
100    /// Builds an immutable completion tree from declarative command specs.
101    ///
102    /// The resulting structure is intentionally plain data so callers can cache
103    /// it, augment it with plugin/provider hints, and pass it into the engine
104    /// without keeping builder state alive.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use osp_cli::completion::{CommandSpec, CompletionTreeBuilder};
110    ///
111    /// let tree = CompletionTreeBuilder.build_from_specs(
112    ///     &[CommandSpec::new("ldap").tooltip("Directory lookups")],
113    ///     [("P".to_string(), "Project fields".to_string())],
114    /// );
115    ///
116    /// assert!(tree.root.children.contains_key("ldap"));
117    /// assert_eq!(tree.pipe_verbs.get("P").map(String::as_str), Some("Project fields"));
118    /// ```
119    pub fn build_from_specs(
120        &self,
121        specs: &[CommandSpec],
122        pipe_verbs: impl IntoIterator<Item = (String, String)>,
123    ) -> CompletionTree {
124        let mut root = CompletionNode::default();
125        for spec in specs {
126            let name = spec.name.clone();
127            assert!(
128                root.children
129                    .insert(name.clone(), Self::node_from_spec(spec))
130                    .is_none(),
131                "duplicate root command spec: {name}"
132            );
133        }
134
135        CompletionTree {
136            root,
137            pipe_verbs: pipe_verbs.into_iter().collect(),
138        }
139    }
140
141    /// Injects `config set` key completions into an existing tree.
142    pub fn apply_config_set_keys(
143        &self,
144        tree: &mut CompletionTree,
145        keys: impl IntoIterator<Item = ConfigKeySpec>,
146    ) {
147        let Some(config_node) = tree.root.children.get_mut("config") else {
148            return;
149        };
150        let Some(set_node) = config_node.children.get_mut("set") else {
151            return;
152        };
153
154        for key in keys {
155            let key_name = key.key.clone();
156            let mut node = CompletionNode {
157                tooltip: key.tooltip,
158                value_key: true,
159                ..CompletionNode::default()
160            };
161            for suggestion in key.value_suggestions {
162                node.children.insert(
163                    suggestion.value.clone(),
164                    CompletionNode {
165                        value_leaf: true,
166                        tooltip: suggestion.meta.clone(),
167                        ..CompletionNode::default()
168                    },
169                );
170            }
171            assert!(
172                set_node.children.insert(key_name.clone(), node).is_none(),
173                "duplicate config set key: {key_name}"
174            );
175        }
176    }
177
178    fn node_from_spec(spec: &CommandSpec) -> CompletionNode {
179        let mut node = CompletionNode {
180            tooltip: spec.tooltip.clone(),
181            sort: spec.sort.clone(),
182            args: spec.args.clone(),
183            flags: spec.flags.clone(),
184            ..CompletionNode::default()
185        };
186
187        for subcommand in &spec.subcommands {
188            let name = subcommand.name.clone();
189            assert!(
190                node.children
191                    .insert(name.clone(), Self::node_from_spec(subcommand))
192                    .is_none(),
193                "duplicate subcommand spec: {name}"
194            );
195        }
196
197        node
198    }
199}
200
201pub(crate) fn command_spec_from_command_def(def: &CommandDef) -> CommandSpec {
202    let mut spec = CommandSpec::new(def.name.clone())
203        .args(def.args.iter().map(arg_node_from_def))
204        .flags(
205            def.flags
206                .iter()
207                .flat_map(flag_entries_from_def)
208                .collect::<Vec<_>>(),
209        )
210        .subcommands(def.subcommands.iter().map(command_spec_from_command_def));
211
212    if let Some(about) = def.about.as_deref() {
213        spec = spec.tooltip(about);
214    }
215    if let Some(sort_key) = def.sort_key.as_deref() {
216        spec = spec.sort(sort_key);
217    }
218    spec
219}
220
221fn arg_node_from_def(arg: &ArgDef) -> ArgNode {
222    let mut node = ArgNode::named(arg.value_name.as_deref().unwrap_or(&arg.id))
223        .suggestions(arg.choices.iter().map(suggestion_from_choice));
224    if let Some(help) = arg.help.as_deref() {
225        node = node.tooltip(help);
226    }
227    if arg.multi {
228        node = node.multi();
229    }
230    if let Some(value_type) = to_completion_value_type(arg.value_kind) {
231        node = node.value_type(value_type);
232    }
233    node
234}
235
236fn flag_entries_from_def(flag: &FlagDef) -> Vec<(String, FlagNode)> {
237    let mut node = FlagNode::new().suggestions(flag.choices.iter().map(suggestion_from_choice));
238    if let Some(help) = flag.help.as_deref() {
239        node = node.tooltip(help);
240    }
241    if !flag.takes_value {
242        node = node.flag_only();
243    }
244    if flag.multi {
245        node = node.multi();
246    }
247    if let Some(value_type) = to_completion_value_type(flag.value_kind) {
248        node = node.value_type(value_type);
249    }
250
251    flag_spellings(flag)
252        .into_iter()
253        .map(|name| (name, node.clone()))
254        .collect()
255}
256
257fn flag_spellings(flag: &FlagDef) -> Vec<String> {
258    let mut names = Vec::new();
259    if let Some(long) = flag.long.as_deref() {
260        names.push(format!("--{long}"));
261    }
262    if let Some(short) = flag.short {
263        names.push(format!("-{short}"));
264    }
265    names.extend(flag.aliases.iter().cloned());
266    names
267}
268
269fn suggestion_from_choice(choice: &ValueChoice) -> SuggestionEntry {
270    let mut entry = SuggestionEntry::value(choice.value.clone());
271    if let Some(meta) = choice.help.as_deref() {
272        entry = entry.meta(meta);
273    }
274    if let Some(display) = choice.display.as_deref() {
275        entry = entry.display(display);
276    }
277    if let Some(sort_key) = choice.sort_key.as_deref() {
278        entry = entry.sort(sort_key);
279    }
280    entry
281}
282
283fn to_completion_value_type(value_kind: Option<ValueKind>) -> Option<crate::completion::ValueType> {
284    match value_kind {
285        Some(ValueKind::Path) => Some(crate::completion::ValueType::Path),
286        Some(ValueKind::Enum | ValueKind::FreeText) | None => None,
287    }
288}
289
290#[derive(Debug, Clone, Default, PartialEq, Eq)]
291/// Declarative `config set` key metadata used for completion nodes.
292pub struct ConfigKeySpec {
293    /// Config key name completed below `config set`.
294    pub key: String,
295    /// Optional description shown for the key.
296    pub tooltip: Option<String>,
297    /// Suggested values for the key.
298    pub value_suggestions: Vec<SuggestionEntry>,
299}
300
301impl ConfigKeySpec {
302    /// Creates a config key spec with the given key name.
303    pub fn new(key: impl Into<String>) -> Self {
304        Self {
305            key: key.into(),
306            ..Self::default()
307        }
308    }
309
310    /// Sets the display tooltip for this config key.
311    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
312        self.tooltip = Some(tooltip.into());
313        self
314    }
315
316    /// Replaces the suggested values for this config key.
317    pub fn value_suggestions(
318        mut self,
319        suggestions: impl IntoIterator<Item = SuggestionEntry>,
320    ) -> Self {
321        self.value_suggestions = suggestions.into_iter().collect();
322        self
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use crate::completion::model::CompletionTree;
329    use crate::core::command_def::{ArgDef, CommandDef, FlagDef, ValueChoice, ValueKind};
330
331    use super::{CommandSpec, CompletionTreeBuilder, ConfigKeySpec, command_spec_from_command_def};
332
333    fn build_tree() -> CompletionTree {
334        CompletionTreeBuilder.build_from_specs(
335            &[CommandSpec::new("config").subcommand(CommandSpec::new("set"))],
336            [("F".to_string(), "Filter".to_string())],
337        )
338    }
339
340    #[test]
341    fn builds_nested_tree_from_specs() {
342        let tree = build_tree();
343        assert!(tree.root.children.contains_key("config"));
344        assert!(
345            tree.root
346                .children
347                .get("config")
348                .and_then(|node| node.children.get("set"))
349                .is_some()
350        );
351    }
352
353    #[test]
354    fn injects_config_key_nodes() {
355        let mut tree = build_tree();
356        CompletionTreeBuilder.apply_config_set_keys(
357            &mut tree,
358            [
359                ConfigKeySpec::new("ui.format"),
360                ConfigKeySpec::new("log.level"),
361            ],
362        );
363
364        let set_node = &tree.root.children["config"].children["set"];
365        assert!(set_node.children.contains_key("ui.format"));
366        assert!(set_node.children.contains_key("log.level"));
367        assert!(set_node.children["ui.format"].value_key);
368    }
369
370    #[test]
371    #[should_panic(expected = "duplicate root command spec")]
372    fn duplicate_root_specs_fail_fast() {
373        let _ = CompletionTreeBuilder.build_from_specs(
374            &[CommandSpec::new("config"), CommandSpec::new("config")],
375            [],
376        );
377    }
378
379    #[test]
380    #[should_panic(expected = "duplicate config set key")]
381    fn duplicate_config_keys_fail_fast() {
382        let mut tree = build_tree();
383        CompletionTreeBuilder.apply_config_set_keys(
384            &mut tree,
385            [
386                ConfigKeySpec::new("ui.format"),
387                ConfigKeySpec::new("ui.format"),
388            ],
389        );
390    }
391
392    #[test]
393    fn command_spec_conversion_preserves_flag_spellings_and_choices_unit() {
394        let def = CommandDef::new("theme")
395            .about("Inspect themes")
396            .sort("10")
397            .arg(
398                ArgDef::new("name")
399                    .help("Theme name")
400                    .value_kind(ValueKind::Path)
401                    .choices([ValueChoice::new("nord").help("Builtin theme")]),
402            )
403            .flag(
404                FlagDef::new("raw")
405                    .long("raw")
406                    .short('r')
407                    .alias("--plain")
408                    .help("Show raw values"),
409            );
410
411        let spec = command_spec_from_command_def(&def);
412
413        assert_eq!(spec.tooltip.as_deref(), Some("Inspect themes"));
414        assert_eq!(spec.sort.as_deref(), Some("10"));
415        assert!(spec.flags.contains_key("--raw"));
416        assert!(spec.flags.contains_key("-r"));
417        assert!(spec.flags.contains_key("--plain"));
418        assert_eq!(spec.args[0].tooltip.as_deref(), Some("Theme name"));
419        assert_eq!(spec.args[0].suggestions[0].value, "nord");
420        assert_eq!(
421            spec.args[0].value_type,
422            Some(crate::completion::ValueType::Path)
423        );
424    }
425}