Skip to main content

bijux_cli/routing/
registry.rs

1//! Routing registry, conflict handling, and introspection APIs.
2
3use std::cmp::max;
4use std::collections::{BTreeMap, BTreeSet};
5
6use crate::contracts::{
7    known_bijux_tool, known_bijux_tools, official_product_namespaces, CommandPath, Namespace,
8    NamespaceMetadata,
9};
10
11/// Route target categories.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RouteTarget {
14    /// Built-in route target.
15    BuiltIn,
16    /// Plugin route target by namespace.
17    Plugin(String),
18}
19
20/// Route resolution error categories.
21#[derive(Debug, thiserror::Error, PartialEq, Eq)]
22pub enum RouteError {
23    /// Namespace is reserved.
24    #[error("namespace is reserved: {0}")]
25    Reserved(String),
26    /// Namespace collides with existing route owner.
27    #[error("namespace conflict: {0}")]
28    Conflict(String),
29    /// Route is unknown.
30    #[error("unknown route: {0}")]
31    Unknown(String),
32    /// Ambiguous route due to multiple owners.
33    #[error("ambiguous route: {0}")]
34    Ambiguous(String),
35}
36
37/// Deterministic routing registry for built-ins and plugins.
38#[derive(Debug, Clone)]
39pub struct RouteRegistry {
40    built_ins: BTreeSet<String>,
41    plugin_namespaces: BTreeSet<String>,
42    plugin_aliases: BTreeMap<String, String>,
43    aliases: BTreeMap<String, String>,
44    reserved: BTreeSet<String>,
45}
46
47impl Default for RouteRegistry {
48    fn default() -> Self {
49        let built_ins =
50            super::model::built_in_route_paths().iter().cloned().collect::<BTreeSet<_>>();
51
52        let aliases = super::model::alias_rewrites()
53            .iter()
54            .map(|(alias, canonical)| ((*alias).to_string(), (*canonical).to_string()))
55            .collect::<BTreeMap<_, _>>();
56
57        let mut reserved = BTreeSet::from([
58            "apps".to_string(),
59            "cli".to_string(),
60            "help".to_string(),
61            "version".to_string(),
62            "doctor".to_string(),
63            "repl".to_string(),
64            "plugins".to_string(),
65            "completion".to_string(),
66            "inspect".to_string(),
67        ]);
68        reserved.extend(official_product_namespaces().iter().map(std::string::ToString::to_string));
69        for tool in known_bijux_tools() {
70            reserved.extend(tool.aliases.iter().map(|alias| (*alias).to_string()));
71        }
72
73        Self {
74            built_ins,
75            plugin_namespaces: BTreeSet::new(),
76            plugin_aliases: BTreeMap::new(),
77            aliases,
78            reserved,
79        }
80    }
81}
82
83impl RouteRegistry {
84    fn blocked_namespace_roots(&self) -> BTreeSet<String> {
85        let mut blocked = BTreeSet::new();
86        for route in &self.built_ins {
87            if let Some(head) = route.split(' ').next() {
88                blocked.insert(head.to_string());
89            }
90        }
91        for alias in self.aliases.keys() {
92            if let Some(head) = alias.split(' ').next() {
93                blocked.insert(head.to_string());
94            }
95        }
96        blocked
97    }
98
99    fn plugin_route_roots(&self) -> BTreeSet<String> {
100        let mut routes = self.plugin_namespaces.clone();
101        routes.extend(self.plugin_aliases.keys().cloned());
102        routes
103    }
104
105    fn validate_plugin_root(&self, raw_namespace: &str) -> Result<String, RouteError> {
106        let ns = normalize_namespace(raw_namespace);
107        if self.reserved.contains(&ns) {
108            return Err(RouteError::Reserved(ns));
109        }
110
111        if self.blocked_namespace_roots().contains(&ns) || self.plugin_route_roots().contains(&ns) {
112            return Err(RouteError::Conflict(ns));
113        }
114
115        Ok(ns)
116    }
117
118    /// Register a plugin namespace with deterministic rejection rules.
119    pub fn register_plugin_namespace(&mut self, raw_namespace: &str) -> Result<(), RouteError> {
120        let ns = self.validate_plugin_root(raw_namespace)?;
121        self.plugin_namespaces.insert(ns);
122        Ok(())
123    }
124
125    /// Register a plugin namespace together with routed top-level aliases.
126    pub fn register_plugin_namespace_with_aliases(
127        &mut self,
128        raw_namespace: &str,
129        raw_aliases: &[String],
130    ) -> Result<(), RouteError> {
131        let namespace = self.validate_plugin_root(raw_namespace)?;
132        let mut aliases = BTreeSet::new();
133        for alias in raw_aliases {
134            let normalized = self.validate_plugin_root(alias)?;
135            if normalized == namespace {
136                return Err(RouteError::Conflict(normalized));
137            }
138            if !aliases.insert(normalized.clone()) {
139                return Err(RouteError::Conflict(normalized));
140            }
141        }
142
143        self.plugin_namespaces.insert(namespace.clone());
144        for alias in aliases {
145            self.plugin_aliases.insert(alias, namespace.clone());
146        }
147        Ok(())
148    }
149
150    /// Resolve normalized command path to a route target.
151    pub fn resolve(&self, normalized_path: &[String]) -> Result<RouteTarget, RouteError> {
152        if normalized_path.is_empty() {
153            return Err(RouteError::Unknown(String::new()));
154        }
155
156        let key = normalized_path.join(" ");
157        let rewritten = self.aliases.get(&key).map_or(key.as_str(), String::as_str);
158
159        if self.built_ins.contains(rewritten) {
160            return Ok(RouteTarget::BuiltIn);
161        }
162
163        let root = rewritten.split(' ').next().unwrap_or_default();
164        if self.plugin_namespaces.contains(root) {
165            if self.built_ins.iter().any(|x| x.split(' ').next() == Some(root)) {
166                return Err(RouteError::Ambiguous(root.to_string()));
167            }
168            return Ok(RouteTarget::Plugin(root.to_string()));
169        }
170
171        if let Some(namespace) = self.plugin_aliases.get(root) {
172            return Ok(RouteTarget::Plugin(namespace.clone()));
173        }
174
175        Err(RouteError::Unknown(rewritten.to_string()))
176    }
177
178    /// Suggest nearest namespace for unknown routes.
179    #[must_use]
180    pub fn suggest_namespace(&self, raw: &str) -> Option<String> {
181        let query = normalize_namespace(raw);
182        let mut universe = BTreeSet::new();
183
184        for route in &self.built_ins {
185            if let Some(head) = route.split(' ').next() {
186                universe.insert(head.to_string());
187            }
188        }
189        for ns in &self.plugin_namespaces {
190            universe.insert(ns.clone());
191        }
192        for alias in self.plugin_aliases.keys() {
193            universe.insert(alias.clone());
194        }
195        for reserved in &self.reserved {
196            universe.insert(reserved.clone());
197        }
198
199        universe.into_iter().max_by_key(|candidate| similarity_score(&query, candidate))
200    }
201
202    /// Build route-tree introspection payload.
203    #[must_use]
204    pub fn route_tree(&self) -> Vec<NamespaceMetadata> {
205        let mut rows = Vec::new();
206
207        for ns in &self.reserved {
208            let owner = if let Some(tool) = known_bijux_tool(ns) {
209                tool.runtime_binary()
210            } else {
211                "bijux-cli".to_string()
212            };
213            rows.push(NamespaceMetadata { name: Namespace(ns.clone()), reserved: true, owner });
214        }
215
216        for ns in &self.plugin_namespaces {
217            rows.push(NamespaceMetadata {
218                name: Namespace(ns.clone()),
219                reserved: false,
220                owner: "plugin".to_string(),
221            });
222        }
223        for (alias, namespace) in &self.plugin_aliases {
224            rows.push(NamespaceMetadata {
225                name: Namespace(alias.clone()),
226                reserved: false,
227                owner: format!("plugin-alias:{namespace}"),
228            });
229        }
230
231        rows.sort_by(|a, b| a.name.0.cmp(&b.name.0));
232        rows
233    }
234
235    /// Render namespace tree lines for snapshot testing and diagnostics.
236    #[must_use]
237    pub fn render_command_tree(&self) -> String {
238        let mut roots = BTreeSet::new();
239        for route in &self.built_ins {
240            if let Some(head) = route.split(' ').next() {
241                roots.insert(head.to_string());
242            }
243        }
244        for alias in self.aliases.keys() {
245            if let Some(head) = alias.split(' ').next() {
246                roots.insert(head.to_string());
247            }
248        }
249        roots.insert("help".to_string());
250        roots.extend(self.plugin_namespaces.iter().cloned());
251        roots.extend(self.plugin_aliases.keys().cloned());
252
253        let mut out = String::new();
254        for root in roots {
255            out.push_str(&root);
256            out.push('\n');
257        }
258        out
259    }
260
261    /// Render built-in route paths for introspection.
262    #[must_use]
263    pub fn built_in_paths(&self) -> Vec<CommandPath> {
264        self.built_ins
265            .iter()
266            .map(|raw| CommandPath {
267                segments: raw.split(' ').map(|segment| Namespace(segment.to_string())).collect(),
268            })
269            .collect()
270    }
271
272    /// Render alias route rewrites for diagnostics introspection.
273    #[must_use]
274    pub fn alias_rewrites(&self) -> Vec<(CommandPath, CommandPath)> {
275        self.aliases.iter().map(|(alias, canonical)| (to_path(alias), to_path(canonical))).collect()
276    }
277
278    /// Render plugin alias rewrites for diagnostics introspection.
279    #[must_use]
280    pub fn plugin_alias_rewrites(&self) -> Vec<(CommandPath, CommandPath)> {
281        self.plugin_aliases
282            .iter()
283            .map(|(alias, namespace)| (to_path(alias), to_path(namespace)))
284            .collect()
285    }
286}
287
288fn similarity_score(left: &str, right: &str) -> usize {
289    let prefix = common_prefix_len(left, right);
290    // Bias toward shared prefix and low edit distance while keeping deterministic ordering.
291    let distance = levenshtein_distance(left, right);
292    let normalized = max(left.chars().count(), right.chars().count());
293    (prefix * 1000) + normalized.saturating_sub(distance)
294}
295
296fn common_prefix_len(left: &str, right: &str) -> usize {
297    left.chars().zip(right.chars()).take_while(|(a, b)| a == b).count()
298}
299
300fn levenshtein_distance(left: &str, right: &str) -> usize {
301    let l: Vec<char> = left.chars().collect();
302    let r: Vec<char> = right.chars().collect();
303    if l.is_empty() {
304        return r.len();
305    }
306    if r.is_empty() {
307        return l.len();
308    }
309
310    let mut prev: Vec<usize> = (0..=r.len()).collect();
311    let mut curr = vec![0; r.len() + 1];
312
313    for (i, lc) in l.iter().enumerate() {
314        curr[0] = i + 1;
315        for (j, rc) in r.iter().enumerate() {
316            let cost = usize::from(lc != rc);
317            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
318        }
319        prev.clone_from(&curr);
320    }
321    prev[r.len()]
322}
323
324fn normalize_namespace(input: &str) -> String {
325    Namespace::normalize(input)
326}
327
328fn to_path(raw: &str) -> CommandPath {
329    CommandPath { segments: raw.split(' ').map(|segment| Namespace(segment.to_string())).collect() }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::{RouteRegistry, RouteTarget};
335
336    #[test]
337    fn registered_plugin_aliases_resolve_to_their_namespace() {
338        let mut registry = RouteRegistry::default();
339        registry
340            .register_plugin_namespace_with_aliases(
341                "alpha",
342                &[String::from("alpha-short"), String::from("alpha-tools")],
343            )
344            .expect("plugin aliases should register");
345
346        let alias_route = registry
347            .resolve(&["alpha-short".to_string(), "run".to_string()])
348            .expect("plugin alias should resolve");
349        assert_eq!(alias_route, RouteTarget::Plugin("alpha".to_string()));
350        assert!(registry
351            .route_tree()
352            .iter()
353            .any(|row| row.name.0 == "alpha-short" && row.owner == "plugin-alias:alpha"));
354    }
355
356    #[test]
357    fn suggestions_include_registered_plugin_aliases() {
358        let mut registry = RouteRegistry::default();
359        registry
360            .register_plugin_namespace_with_aliases("alpha", &[String::from("alpha-short")])
361            .expect("plugin alias should register");
362        assert_eq!(registry.suggest_namespace("alph-short").as_deref(), Some("alpha-short"));
363    }
364}