1use 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#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum RouteTarget {
13 BuiltIn,
15 Plugin(String),
17}
18
19#[derive(Debug, thiserror::Error, PartialEq, Eq)]
21pub enum RouteError {
22 #[error("namespace is reserved: {0}")]
24 Reserved(String),
25 #[error("namespace conflict: {0}")]
27 Conflict(String),
28 #[error("unknown route: {0}")]
30 Unknown(String),
31 #[error("ambiguous route: {0}")]
33 Ambiguous(String),
34}
35
36#[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}