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