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};
17use thiserror::Error;
18
19/// Error returned when declarative completion metadata cannot be lowered into a
20/// valid immutable tree.
21#[derive(Debug, Clone, PartialEq, Eq, Error)]
22pub enum CompletionTreeBuildError {
23    /// Two root command specs used the same command name.
24    #[error("duplicate root command spec: {name}")]
25    DuplicateRootCommand {
26        /// Duplicate root command name.
27        name: String,
28    },
29    /// Two subcommand specs under the same parent path used the same name.
30    #[error("duplicate subcommand spec under {parent_path}: {name}")]
31    DuplicateSubcommand {
32        /// Parent command path that already contains `name`.
33        parent_path: String,
34        /// Duplicate subcommand name.
35        name: String,
36    },
37    /// Two injected `config set` key specs used the same key.
38    #[error("duplicate config set key: {key}")]
39    DuplicateConfigSetKey {
40        /// Duplicate `config set` key name.
41        key: String,
42    },
43}
44
45impl CompletionTreeBuildError {
46    fn duplicate_subcommand(parent_path: &[String], name: &str) -> Self {
47        Self::DuplicateSubcommand {
48            parent_path: parent_path.join(" "),
49            name: name.to_string(),
50        }
51    }
52}
53
54/// Declarative command description used to build a completion tree.
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56#[must_use]
57pub struct CommandSpec {
58    /// Command or subcommand name.
59    pub name: String,
60    /// Optional description shown alongside the command.
61    pub tooltip: Option<String>,
62    /// Optional hidden sort key for display ordering.
63    pub sort: Option<String>,
64    /// Positional arguments accepted by this command.
65    pub args: Vec<ArgNode>,
66    /// Flags accepted by this command.
67    pub flags: BTreeMap<String, FlagNode>,
68    /// Nested subcommands below this command.
69    pub subcommands: Vec<CommandSpec>,
70}
71
72impl CommandSpec {
73    /// Starts a declarative command spec with the given command name.
74    pub fn new(name: impl Into<String>) -> Self {
75        Self {
76            name: name.into(),
77            ..Self::default()
78        }
79    }
80
81    /// Attaches the description shown alongside this command in completion UIs.
82    ///
83    /// If omitted, completion UIs show no description for this command.
84    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
85        self.tooltip = Some(tooltip.into());
86        self
87    }
88
89    /// Attaches a hidden sort key used to stabilize menu ordering.
90    ///
91    /// If omitted, the command carries no explicit sort hint.
92    pub fn sort(mut self, sort: impl Into<String>) -> Self {
93        self.sort = Some(sort.into());
94        self
95    }
96
97    /// Appends one positional argument definition.
98    ///
99    /// If omitted, the command contributes no positional completion metadata.
100    pub fn arg(mut self, arg: ArgNode) -> Self {
101        self.args.push(arg);
102        self
103    }
104
105    /// Extends the command with positional argument definitions.
106    ///
107    /// If omitted, the command contributes no positional completion metadata.
108    pub fn args(mut self, args: impl IntoIterator<Item = ArgNode>) -> Self {
109        self.args.extend(args);
110        self
111    }
112
113    /// Adds one flag definition keyed by its spelling.
114    ///
115    /// If omitted, the command contributes no flag completion metadata.
116    pub fn flag(mut self, name: impl Into<String>, flag: FlagNode) -> Self {
117        self.flags.insert(name.into(), flag);
118        self
119    }
120
121    /// Extends the command with multiple flag definitions.
122    ///
123    /// If omitted, the command contributes no flag completion metadata.
124    pub fn flags(mut self, flags: impl IntoIterator<Item = (String, FlagNode)>) -> Self {
125        self.flags.extend(flags);
126        self
127    }
128
129    /// Appends one nested subcommand.
130    ///
131    /// If omitted, the command contributes no nested subcommand metadata.
132    pub fn subcommand(mut self, subcommand: CommandSpec) -> Self {
133        self.subcommands.push(subcommand);
134        self
135    }
136
137    /// Extends the command with nested subcommands.
138    ///
139    /// If omitted, the command contributes no nested subcommand metadata.
140    pub fn subcommands(mut self, subcommands: impl IntoIterator<Item = CommandSpec>) -> Self {
141        self.subcommands.extend(subcommands);
142        self
143    }
144}
145
146#[derive(Debug, Clone, Default)]
147/// Builds immutable completion trees from command and config metadata.
148///
149/// This is the canonical builder surface for completion-tree construction.
150pub struct CompletionTreeBuilder;
151
152impl CompletionTreeBuilder {
153    /// Builds an immutable completion tree from declarative command specs.
154    ///
155    /// The resulting structure is intentionally plain data so callers can cache
156    /// it, augment it with plugin/provider hints, and pass it into the engine
157    /// without keeping builder state alive.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`CompletionTreeBuildError::DuplicateRootCommand`] when `specs`
162    /// contains duplicate root command names, or
163    /// [`CompletionTreeBuildError::DuplicateSubcommand`] when any nested
164    /// subcommand list reuses a name under the same parent path.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use osp_cli::completion::{CommandSpec, CompletionTreeBuilder};
170    ///
171    /// let tree = CompletionTreeBuilder.build_from_specs(
172    ///     &[CommandSpec::new("config")
173    ///         .tooltip("Runtime configuration")
174    ///         .subcommand(CommandSpec::new("set"))],
175    ///     [("P".to_string(), "Project fields".to_string())],
176    /// )
177    /// .expect("tree should build");
178    ///
179    /// assert!(tree.root.children.contains_key("config"));
180    /// assert!(tree.root.children["config"].children.contains_key("set"));
181    /// assert_eq!(tree.pipe_verbs.get("P").map(String::as_str), Some("Project fields"));
182    /// ```
183    pub fn build_from_specs(
184        &self,
185        specs: &[CommandSpec],
186        pipe_verbs: impl IntoIterator<Item = (String, String)>,
187    ) -> Result<CompletionTree, CompletionTreeBuildError> {
188        let mut root = CompletionNode::default();
189        for spec in specs {
190            let name = spec.name.clone();
191            let node = Self::node_from_spec(spec, &[])?;
192            if root.children.insert(name.clone(), node).is_some() {
193                return Err(CompletionTreeBuildError::DuplicateRootCommand { name });
194            }
195        }
196
197        Ok(CompletionTree {
198            root,
199            pipe_verbs: pipe_verbs.into_iter().collect(),
200        })
201    }
202
203    /// Injects `config set` key completions into an existing tree.
204    ///
205    /// This is a no-op when the tree does not contain a `config set` path.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`CompletionTreeBuildError::DuplicateConfigSetKey`] when `keys`
210    /// contains duplicate config-set key names.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use osp_cli::completion::{CommandSpec, CompletionTreeBuilder, ConfigKeySpec};
216    ///
217    /// let mut tree = CompletionTreeBuilder.build_from_specs(
218    ///     &[CommandSpec::new("config").subcommand(CommandSpec::new("set"))],
219    ///     [],
220    /// )
221    /// .expect("tree should build");
222    /// CompletionTreeBuilder.apply_config_set_keys(
223    ///     &mut tree,
224    ///     [
225    ///         ConfigKeySpec::new("ui.format"),
226    ///         ConfigKeySpec::new("log.level"),
227    ///     ],
228    /// )
229    /// .expect("config keys should inject");
230    ///
231    /// let set_node = &tree.root.children["config"].children["set"];
232    /// assert!(set_node.children.contains_key("ui.format"));
233    /// assert!(set_node.children.contains_key("log.level"));
234    /// assert!(set_node.children["ui.format"].value_key);
235    /// ```
236    pub fn apply_config_set_keys(
237        &self,
238        tree: &mut CompletionTree,
239        keys: impl IntoIterator<Item = ConfigKeySpec>,
240    ) -> Result<(), CompletionTreeBuildError> {
241        let Some(config_node) = tree.root.children.get_mut("config") else {
242            return Ok(());
243        };
244        let Some(set_node) = config_node.children.get_mut("set") else {
245            return Ok(());
246        };
247
248        for key in keys {
249            let key_name = key.key.clone();
250            let mut node = CompletionNode {
251                tooltip: key.tooltip,
252                value_key: true,
253                ..CompletionNode::default()
254            };
255            for suggestion in key.value_suggestions {
256                node.children.insert(
257                    suggestion.value.clone(),
258                    CompletionNode {
259                        value_leaf: true,
260                        tooltip: suggestion.meta.clone(),
261                        ..CompletionNode::default()
262                    },
263                );
264            }
265            if set_node.children.insert(key_name.clone(), node).is_some() {
266                return Err(CompletionTreeBuildError::DuplicateConfigSetKey { key: key_name });
267            }
268        }
269
270        Ok(())
271    }
272
273    fn node_from_spec(
274        spec: &CommandSpec,
275        parent_path: &[String],
276    ) -> Result<CompletionNode, CompletionTreeBuildError> {
277        let mut node = CompletionNode {
278            tooltip: spec.tooltip.clone(),
279            sort: spec.sort.clone(),
280            args: spec.args.clone(),
281            flags: spec.flags.clone(),
282            ..CompletionNode::default()
283        };
284
285        let mut path = parent_path.to_vec();
286        path.push(spec.name.clone());
287        for subcommand in &spec.subcommands {
288            let name = subcommand.name.clone();
289            let child = Self::node_from_spec(subcommand, &path)?;
290            if node.children.insert(name.clone(), child).is_some() {
291                return Err(CompletionTreeBuildError::duplicate_subcommand(&path, &name));
292            }
293        }
294
295        Ok(node)
296    }
297}
298
299pub(crate) fn command_spec_from_command_def(def: &CommandDef) -> CommandSpec {
300    CommandSpec {
301        name: def.name.clone(),
302        tooltip: def.about.clone(),
303        sort: def.sort_key.clone(),
304        args: def.args.iter().map(arg_node_from_def).collect(),
305        flags: def.flags.iter().flat_map(flag_entries_from_def).collect(),
306        subcommands: def
307            .subcommands
308            .iter()
309            .map(command_spec_from_command_def)
310            .collect(),
311    }
312}
313
314fn arg_node_from_def(arg: &ArgDef) -> ArgNode {
315    ArgNode {
316        name: Some(arg.value_name.as_deref().unwrap_or(&arg.id).to_string()),
317        tooltip: arg.help.clone(),
318        multi: arg.multi,
319        value_type: to_completion_value_type(arg.value_kind),
320        suggestions: arg.choices.iter().map(suggestion_from_choice).collect(),
321    }
322}
323
324fn flag_entries_from_def(flag: &FlagDef) -> Vec<(String, FlagNode)> {
325    let node = FlagNode {
326        tooltip: flag.help.clone(),
327        flag_only: !flag.takes_value,
328        multi: flag.multi,
329        value_type: to_completion_value_type(flag.value_kind),
330        suggestions: flag.choices.iter().map(suggestion_from_choice).collect(),
331        ..FlagNode::default()
332    };
333
334    flag_spellings(flag)
335        .into_iter()
336        .map(|name| (name, node.clone()))
337        .collect()
338}
339
340fn flag_spellings(flag: &FlagDef) -> Vec<String> {
341    let mut names = Vec::new();
342    if let Some(long) = flag.long.as_deref() {
343        names.push(format!("--{long}"));
344    }
345    if let Some(short) = flag.short {
346        names.push(format!("-{short}"));
347    }
348    names.extend(flag.aliases.iter().cloned());
349    names
350}
351
352fn suggestion_from_choice(choice: &ValueChoice) -> SuggestionEntry {
353    SuggestionEntry {
354        value: choice.value.clone(),
355        meta: choice.help.clone(),
356        display: choice.display.clone(),
357        sort: choice.sort_key.clone(),
358    }
359}
360
361fn to_completion_value_type(value_kind: Option<ValueKind>) -> Option<crate::completion::ValueType> {
362    match value_kind {
363        Some(ValueKind::Path) => Some(crate::completion::ValueType::Path),
364        Some(ValueKind::Enum | ValueKind::FreeText) | None => None,
365    }
366}
367
368/// Declarative `config set` key metadata used for completion nodes.
369#[derive(Debug, Clone, Default, PartialEq, Eq)]
370#[must_use]
371pub struct ConfigKeySpec {
372    /// Config key name completed below `config set`.
373    pub key: String,
374    /// Optional description shown for the key.
375    pub tooltip: Option<String>,
376    /// Suggested values for the key.
377    pub value_suggestions: Vec<SuggestionEntry>,
378}
379
380impl ConfigKeySpec {
381    /// Creates a config key spec with the given key name.
382    pub fn new(key: impl Into<String>) -> Self {
383        Self {
384            key: key.into(),
385            ..Self::default()
386        }
387    }
388
389    /// Sets the display tooltip for this config key.
390    ///
391    /// If omitted, completion UIs show no description for the key.
392    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
393        self.tooltip = Some(tooltip.into());
394        self
395    }
396
397    /// Replaces the suggested values for this config key.
398    ///
399    /// If omitted, the key contributes no value suggestions.
400    pub fn value_suggestions(
401        mut self,
402        suggestions: impl IntoIterator<Item = SuggestionEntry>,
403    ) -> Self {
404        self.value_suggestions = suggestions.into_iter().collect();
405        self
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use crate::completion::model::CompletionTree;
412    use crate::core::command_def::{ArgDef, CommandDef, FlagDef, ValueChoice, ValueKind};
413
414    use super::{
415        CommandSpec, CompletionTreeBuildError, CompletionTreeBuilder, ConfigKeySpec,
416        command_spec_from_command_def,
417    };
418
419    fn build_tree() -> CompletionTree {
420        CompletionTreeBuilder
421            .build_from_specs(
422                &[CommandSpec::new("config").subcommand(CommandSpec::new("set"))],
423                [("F".to_string(), "Filter".to_string())],
424            )
425            .expect("tree should build")
426    }
427
428    #[test]
429    fn duplicate_root_specs_return_an_error() {
430        let err = CompletionTreeBuilder
431            .build_from_specs(
432                &[CommandSpec::new("config"), CommandSpec::new("config")],
433                [],
434            )
435            .expect_err("duplicate root command should fail");
436
437        assert_eq!(
438            err,
439            CompletionTreeBuildError::DuplicateRootCommand {
440                name: "config".to_string()
441            }
442        );
443    }
444
445    #[test]
446    fn duplicate_config_keys_return_an_error() {
447        let mut tree = build_tree();
448        let err = CompletionTreeBuilder
449            .apply_config_set_keys(
450                &mut tree,
451                [
452                    ConfigKeySpec::new("ui.format"),
453                    ConfigKeySpec::new("ui.format"),
454                ],
455            )
456            .expect_err("duplicate config key should fail");
457
458        assert_eq!(
459            err,
460            CompletionTreeBuildError::DuplicateConfigSetKey {
461                key: "ui.format".to_string()
462            }
463        );
464    }
465
466    #[test]
467    fn duplicate_subcommands_return_an_error() {
468        let err = CompletionTreeBuilder
469            .build_from_specs(
470                &[CommandSpec::new("config")
471                    .subcommands([CommandSpec::new("set"), CommandSpec::new("set")])],
472                [],
473            )
474            .expect_err("duplicate subcommand should fail");
475
476        assert_eq!(
477            err,
478            CompletionTreeBuildError::DuplicateSubcommand {
479                parent_path: "config".to_string(),
480                name: "set".to_string()
481            }
482        );
483    }
484
485    #[test]
486    fn command_spec_conversion_preserves_flag_spellings_and_choices_unit() {
487        let def = CommandDef::new("theme")
488            .about("Inspect themes")
489            .sort("10")
490            .arg(
491                ArgDef::new("name")
492                    .help("Theme name")
493                    .value_kind(ValueKind::Path)
494                    .choices([ValueChoice::new("nord").help("Builtin theme")]),
495            )
496            .flag(
497                FlagDef::new("raw")
498                    .long("raw")
499                    .short('r')
500                    .alias("--plain")
501                    .help("Show raw values"),
502            );
503
504        let spec = command_spec_from_command_def(&def);
505
506        assert_eq!(spec.tooltip.as_deref(), Some("Inspect themes"));
507        assert_eq!(spec.sort.as_deref(), Some("10"));
508        assert!(spec.flags.contains_key("--raw"));
509        assert!(spec.flags.contains_key("-r"));
510        assert!(spec.flags.contains_key("--plain"));
511        assert_eq!(spec.args[0].tooltip.as_deref(), Some("Theme name"));
512        assert_eq!(spec.args[0].suggestions[0].value, "nord");
513        assert_eq!(
514            spec.args[0].value_type,
515            Some(crate::completion::ValueType::Path)
516        );
517    }
518}