1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RouteTarget {
14 BuiltIn,
16 Plugin(String),
18}
19
20#[derive(Debug, thiserror::Error, PartialEq, Eq)]
22pub enum RouteError {
23 #[error("namespace is reserved: {0}")]
25 Reserved(String),
26 #[error("namespace conflict: {0}")]
28 Conflict(String),
29 #[error("unknown route: {0}")]
31 Unknown(String),
32 #[error("ambiguous route: {0}")]
34 Ambiguous(String),
35}
36
37#[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}