Skip to main content

mandible_core/
noderef.rs

1//! Addressing: [`NodeRef`] and [`FlagKey`], the single addressing type used
2//! by search, the clipboard, and the cache. See spec §4.3.
3
4use crate::node::CommandNode;
5use serde::{Deserialize, Serialize};
6
7/// A reference to a command or a specific flag within the tree, by name path.
8///
9/// Paths are name-based: `["git", "rebase"]` addresses `git rebase`, and
10/// include the root's own name as the first segment.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum NodeRef {
13    /// A command or subcommand, by full name path from the root.
14    Command(Vec<String>),
15    /// A flag on a command, by the command's name path plus the flag's key.
16    Flag {
17        /// The owning command's full name path from the root.
18        path: Vec<String>,
19        /// Which flag on that command.
20        key: FlagKey,
21    },
22}
23
24/// Identifies a flag within a node's flag list.
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum FlagKey {
27    /// By long spelling, without the leading `--`.
28    Long(String),
29    /// By short spelling, without the leading `-`.
30    Short(char),
31}
32
33/// Resolve a name path to the [`CommandNode`] it addresses, starting from
34/// `root`. `path` includes `root`'s own name as its first element.
35///
36/// Walks `subcommands` by exact name (or alias) match at each level,
37/// consuming exactly one path segment per level. This deliberately does
38/// **not** special-case a subcommand sharing its parent's name — there is no
39/// "skip a segment that matches the current node" shortcut, which would
40/// silently mis-resolve that case (spec §4.3).
41pub fn resolve<'a>(root: &'a CommandNode, path: &[String]) -> Option<&'a CommandNode> {
42    let mut segments = path.iter();
43    let first = segments.next()?;
44    if !names_match(root, first) {
45        return None;
46    }
47    let mut current = root;
48    for seg in segments {
49        current = current.subcommands.iter().find(|c| names_match(c, seg))?;
50    }
51    Some(current)
52}
53
54/// Mutable counterpart of [`resolve`], used by the extraction runner to
55/// splice a newly-extracted subtree into the cached tree in place.
56pub fn resolve_mut<'a>(root: &'a mut CommandNode, path: &[String]) -> Option<&'a mut CommandNode> {
57    let mut segments = path.iter();
58    let first = segments.next()?;
59    if !names_match(root, first) {
60        return None;
61    }
62    let mut current = root;
63    for seg in segments {
64        current = current
65            .subcommands
66            .iter_mut()
67            .find(|c| names_match(c, seg))?;
68    }
69    Some(current)
70}
71
72fn names_match(node: &CommandNode, segment: &str) -> bool {
73    node.name == segment || node.aliases.iter().any(|a| a == segment)
74}
75
76/// Resolve a [`NodeRef`] to the flag it addresses, if any.
77pub fn resolve_flag<'a>(
78    root: &'a CommandNode,
79    path: &[String],
80    key: &FlagKey,
81) -> Option<&'a crate::node::Flag> {
82    let node = resolve(root, path)?;
83    node.flags.iter().find(|f| f.matches_key(key))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::provenance::{Provenance, Source};
90
91    fn leaf(name: &str) -> CommandNode {
92        CommandNode::new(name, Provenance::single(Source::HelpText))
93    }
94
95    #[test]
96    fn resolves_root() {
97        let root = leaf("git");
98        let found = resolve(&root, &["git".to_string()]).unwrap();
99        assert_eq!(found.name, "git");
100    }
101
102    #[test]
103    fn resolves_nested_path() {
104        let mut root = leaf("git");
105        let mut rebase = leaf("rebase");
106        rebase.subcommands.push(leaf("--onto-helper"));
107        root.subcommands.push(rebase);
108        let path = vec!["git".to_string(), "rebase".to_string()];
109        let found = resolve(&root, &path).unwrap();
110        assert_eq!(found.name, "rebase");
111    }
112
113    #[test]
114    fn returns_none_for_unknown_segment() {
115        let root = leaf("git");
116        let path = vec!["git".to_string(), "nonexistent".to_string()];
117        assert!(resolve(&root, &path).is_none());
118    }
119
120    /// A subcommand sharing its parent's name must resolve correctly: this
121    /// is the exact regression case called out in spec §4.3. A buggy
122    /// resolver that "skips a segment equal to the current node's name"
123    /// would treat `["a", "a", "b"]` as if the second `a` were a no-op and
124    /// incorrectly look for `b` directly under the outer `a`, or would
125    /// resolve to the wrong `a`.
126    #[test]
127    fn subcommand_sharing_parent_name_resolves_correctly() {
128        // a
129        // └── a (a different node, deliberately also named "a")
130        //     └── b
131        let mut inner_a = leaf("a");
132        inner_a.subcommands.push(leaf("b"));
133        // Mark the inner node's child distinguishably so we can prove we
134        // landed on the right subtree.
135        inner_a.subcommands[0].summary = None;
136        let mut outer_a = leaf("a");
137        outer_a.subcommands.push(inner_a);
138        // outer "a" has no "b" child directly - only inner "a" does.
139        let path = vec!["a".to_string(), "a".to_string(), "b".to_string()];
140        let found = resolve(&outer_a, &path).expect("must resolve through nested same-named node");
141        assert_eq!(found.name, "b");
142
143        // And a path that tries to skip the inner "a" must fail, proving
144        // there's no skip-shortcut silently making ["a", "b"] work too.
145        let bad_path = vec!["a".to_string(), "b".to_string()];
146        assert!(resolve(&outer_a, &bad_path).is_none());
147    }
148
149    #[test]
150    fn resolves_via_alias() {
151        let mut root = leaf("git");
152        let mut add = leaf("add");
153        add.aliases.push("stage".to_string());
154        root.subcommands.push(add);
155        let path = vec!["git".to_string(), "stage".to_string()];
156        let found = resolve(&root, &path).unwrap();
157        assert_eq!(found.name, "add");
158    }
159
160    #[test]
161    fn resolve_mut_allows_splicing() {
162        let mut root = leaf("git");
163        root.subcommands.push(leaf("rebase"));
164        {
165            let node = resolve_mut(&mut root, &["git".to_string(), "rebase".to_string()]).unwrap();
166            node.children_filled = true;
167        }
168        assert!(root.subcommands[0].children_filled);
169    }
170}