1use std::collections::{HashMap, HashSet};
7
8use super::error::suggest;
9use super::token::Token;
10use super::types::CommandPath;
11
12const GLOBAL_COMMANDS: &[&str] = &["help", "version", "commands"];
14
15#[derive(Debug)]
17pub enum RouteResolution {
18 Resolved {
20 path: CommandPath,
21 remaining_tokens: Vec<Token>,
22 },
23 GlobalCommand {
25 name: String,
26 remaining_tokens: Vec<Token>,
27 },
28 PartialDomain {
30 domain: String,
31 remaining_tokens: Vec<Token>,
32 },
33 PartialResource {
35 domain: String,
36 resource: String,
37 remaining_tokens: Vec<Token>,
38 },
39 Unknown {
41 tokens: Vec<String>,
42 suggestions: Vec<String>,
43 },
44}
45
46struct ResourceEntry {
51 verbs: HashSet<String>,
52 verb_aliases: HashMap<String, String>,
53}
54
55struct DomainEntry {
56 resources: HashMap<String, ResourceEntry>,
57 aliases: HashMap<String, String>,
58}
59
60pub struct RouteTree {
62 domains: HashMap<String, DomainEntry>,
63 aliases: HashMap<String, String>,
64}
65
66impl RouteTree {
67 pub fn build(
73 domains: &[(String, Vec<String>)],
74 resources: &[(String, String, Vec<String>)],
75 verbs: &[(String, String, String, Vec<String>)],
76 ) -> Self {
77 let mut tree_domains: HashMap<String, DomainEntry> = HashMap::new();
78 let mut tree_aliases: HashMap<String, String> = HashMap::new();
79
80 for (name, aliases) in domains {
82 for alias in aliases {
83 tree_aliases.insert(alias.clone(), name.clone());
84 }
85 tree_domains
86 .entry(name.clone())
87 .or_insert_with(|| DomainEntry {
88 resources: HashMap::new(),
89 aliases: HashMap::new(),
90 });
91 }
92
93 for (domain, resource, aliases) in resources {
95 let domain_entry = tree_domains
96 .entry(domain.clone())
97 .or_insert_with(|| DomainEntry {
98 resources: HashMap::new(),
99 aliases: HashMap::new(),
100 });
101 for alias in aliases {
102 domain_entry.aliases.insert(alias.clone(), resource.clone());
103 }
104 domain_entry
105 .resources
106 .entry(resource.clone())
107 .or_insert_with(|| ResourceEntry {
108 verbs: HashSet::new(),
109 verb_aliases: HashMap::new(),
110 });
111 }
112
113 for (domain, resource, verb, aliases) in verbs {
115 let domain_entry = tree_domains
116 .entry(domain.clone())
117 .or_insert_with(|| DomainEntry {
118 resources: HashMap::new(),
119 aliases: HashMap::new(),
120 });
121 let resource_entry = domain_entry
122 .resources
123 .entry(resource.clone())
124 .or_insert_with(|| ResourceEntry {
125 verbs: HashSet::new(),
126 verb_aliases: HashMap::new(),
127 });
128 resource_entry.verbs.insert(verb.clone());
129 for alias in aliases {
130 resource_entry
131 .verb_aliases
132 .insert(alias.clone(), verb.clone());
133 }
134 }
135
136 Self {
137 domains: tree_domains,
138 aliases: tree_aliases,
139 }
140 }
141
142 pub fn resolve(&self, tokens: &[Token]) -> RouteResolution {
144 let mut positionals: Vec<&str> = Vec::new();
146 let mut first_non_pos_idx: Option<usize> = None;
147
148 for (i, token) in tokens.iter().enumerate() {
149 match token {
150 Token::Positional(val) => positionals.push(val.as_str()),
151 _ => {
152 first_non_pos_idx = Some(i);
153 break;
154 }
155 }
156 }
157
158 let build_remaining = |consumed: usize| -> Vec<Token> {
160 let start = if consumed < positionals.len() {
161 consumed
163 } else {
164 positionals.len()
165 };
166 let mut remaining = Vec::new();
167 for &p in &positionals[start..] {
169 remaining.push(Token::Positional(p.to_string()));
170 }
171 let tail_start = first_non_pos_idx.unwrap_or(tokens.len());
173 remaining.extend_from_slice(&tokens[tail_start..]);
174 remaining
175 };
176
177 if positionals.is_empty() {
179 return RouteResolution::Unknown {
180 tokens: vec![],
181 suggestions: self.domain_names(),
182 };
183 }
184
185 let first = positionals[0];
186
187 if GLOBAL_COMMANDS.contains(&first) {
189 return RouteResolution::GlobalCommand {
190 name: first.to_string(),
191 remaining_tokens: build_remaining(1),
192 };
193 }
194
195 let domain = if self.domains.contains_key(first) {
197 first.to_string()
198 } else if let Some(canonical) = self.aliases.get(first) {
199 canonical.clone()
200 } else {
201 let candidates = self.domain_names();
203 let candidate_refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
204 let suggestions = suggest(first, &candidate_refs, 3);
205 return RouteResolution::Unknown {
206 tokens: positionals.iter().map(|s| s.to_string()).collect(),
207 suggestions,
208 };
209 };
210
211 let domain_entry = self.domains.get(&domain).expect("domain just resolved");
212
213 if positionals.len() == 1 {
215 return RouteResolution::PartialDomain {
216 domain,
217 remaining_tokens: build_remaining(1),
218 };
219 }
220
221 let second = positionals[1];
222
223 let resource = if domain_entry.resources.contains_key(second) {
225 second.to_string()
226 } else if let Some(canonical) = domain_entry.aliases.get(second) {
227 canonical.clone()
228 } else {
229 if positionals.len() >= 3 {
231 if let Some(resolution) = self.try_compat_swap(
232 &domain,
233 domain_entry,
234 positionals[1],
235 positionals[2],
236 &build_remaining(3),
237 ) {
238 return resolution;
239 }
240 }
241 return RouteResolution::PartialDomain {
243 domain,
244 remaining_tokens: build_remaining(1),
245 };
246 };
247
248 if positionals.len() == 2 {
250 return RouteResolution::PartialResource {
251 domain,
252 resource,
253 remaining_tokens: build_remaining(2),
254 };
255 }
256
257 let third = positionals[2];
258 let resource_entry = domain_entry
259 .resources
260 .get(&resource)
261 .expect("resource just resolved");
262
263 if resource_entry.verbs.contains(third) {
265 return RouteResolution::Resolved {
266 path: CommandPath {
267 domain,
268 resource: Some(resource),
269 verb: Some(third.to_string()),
270 },
271 remaining_tokens: build_remaining(3),
272 };
273 }
274
275 if let Some(canonical_verb) = resource_entry.verb_aliases.get(third) {
276 return RouteResolution::Resolved {
277 path: CommandPath {
278 domain,
279 resource: Some(resource),
280 verb: Some(canonical_verb.clone()),
281 },
282 remaining_tokens: build_remaining(3),
283 };
284 }
285
286 if let Some(resolution) = self.try_compat_swap(
288 &domain,
289 domain_entry,
290 positionals[1],
291 positionals[2],
292 &build_remaining(3),
293 ) {
294 return resolution;
295 }
296
297 RouteResolution::PartialResource {
299 domain,
300 resource,
301 remaining_tokens: build_remaining(2),
302 }
303 }
304
305 pub fn domains(&self) -> Vec<&str> {
307 let mut names: Vec<&str> = self.domains.keys().map(|s| s.as_str()).collect();
308 names.sort();
309 names
310 }
311
312 pub fn resources(&self, domain: &str) -> Vec<&str> {
314 self.domains
315 .get(domain)
316 .map(|entry| {
317 let mut names: Vec<&str> = entry.resources.keys().map(|s| s.as_str()).collect();
318 names.sort();
319 names
320 })
321 .unwrap_or_default()
322 }
323
324 pub fn verbs(&self, domain: &str, resource: &str) -> Vec<&str> {
326 self.domains
327 .get(domain)
328 .and_then(|d| d.resources.get(resource))
329 .map(|r| {
330 let mut names: Vec<&str> = r.verbs.iter().map(|s| s.as_str()).collect();
331 names.sort();
332 names
333 })
334 .unwrap_or_default()
335 }
336
337 fn domain_names(&self) -> Vec<String> {
342 let mut names: Vec<String> = self.domains.keys().cloned().collect();
343 names.sort();
344 names
345 }
346
347 fn try_compat_swap(
350 &self,
351 domain: &str,
352 domain_entry: &DomainEntry,
353 first_token: &str,
354 second_token: &str,
355 remaining: &[Token],
356 ) -> Option<RouteResolution> {
357 let swapped_resource = if domain_entry.resources.contains_key(second_token) {
359 second_token.to_string()
360 } else {
361 domain_entry.aliases.get(second_token)?.clone()
362 };
363
364 let resource_entry = domain_entry.resources.get(&swapped_resource)?;
365
366 let swapped_verb = if resource_entry.verbs.contains(first_token) {
367 first_token.to_string()
368 } else {
369 resource_entry.verb_aliases.get(first_token)?.clone()
370 };
371
372 Some(RouteResolution::Resolved {
373 path: CommandPath {
374 domain: domain.to_string(),
375 resource: Some(swapped_resource),
376 verb: Some(swapped_verb),
377 },
378 remaining_tokens: remaining.to_vec(),
379 })
380 }
381}
382
383#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::cli::token::Token;
391
392 fn test_tree() -> RouteTree {
394 let domains = vec![
395 ("data".into(), vec![]),
396 ("server".into(), vec!["s".into(), "srv".into()]),
397 ("index".into(), vec![]),
398 ("graph".into(), vec!["g".into()]),
399 ];
400 let resources = vec![
401 ("data".into(), "collection".into(), vec!["col".into()]),
402 ("server".into(), "grpc".into(), vec!["rpc".into()]),
403 ("server".into(), "http".into(), vec![]),
404 ("index".into(), "vector".into(), vec![]),
405 ("graph".into(), "node".into(), vec!["n".into()]),
406 ];
407 let verbs = vec![
408 ("data".into(), "collection".into(), "list".into(), vec![]),
409 ("data".into(), "collection".into(), "create".into(), vec![]),
410 (
411 "server".into(),
412 "grpc".into(),
413 "start".into(),
414 vec!["s".into()],
415 ),
416 ("server".into(), "http".into(), "start".into(), vec![]),
417 ("index".into(), "vector".into(), "build".into(), vec![]),
418 (
419 "graph".into(),
420 "node".into(),
421 "query".into(),
422 vec!["q".into()],
423 ),
424 ("graph".into(), "node".into(), "traverse".into(), vec![]),
425 ];
426 RouteTree::build(&domains, &resources, &verbs)
427 }
428
429 fn pos(s: &str) -> Token {
430 Token::Positional(s.to_string())
431 }
432
433 fn long_flag(name: &str) -> Token {
434 Token::LongFlag {
435 name: name.to_string(),
436 value: None,
437 }
438 }
439
440 #[test]
444 fn test_resolve_full_command() {
445 let tree = test_tree();
446 let tokens = vec![pos("data"), pos("collection"), pos("list")];
447 match tree.resolve(&tokens) {
448 RouteResolution::Resolved {
449 path,
450 remaining_tokens,
451 } => {
452 assert_eq!(path.domain, "data");
453 assert_eq!(path.resource.as_deref(), Some("collection"));
454 assert_eq!(path.verb.as_deref(), Some("list"));
455 assert!(remaining_tokens.is_empty());
456 }
457 other => panic!("expected Resolved, got {:?}", other),
458 }
459 }
460
461 #[test]
465 fn test_resolve_with_aliases() {
466 let tree = test_tree();
467 let tokens = vec![pos("s"), pos("grpc"), pos("s")];
468 match tree.resolve(&tokens) {
469 RouteResolution::Resolved {
470 path,
471 remaining_tokens,
472 } => {
473 assert_eq!(path.domain, "server");
474 assert_eq!(path.resource.as_deref(), Some("grpc"));
475 assert_eq!(path.verb.as_deref(), Some("start"));
476 assert!(remaining_tokens.is_empty());
477 }
478 other => panic!("expected Resolved, got {:?}", other),
479 }
480 }
481
482 #[test]
486 fn test_resolve_global_help() {
487 let tree = test_tree();
488 let tokens = vec![pos("help")];
489 match tree.resolve(&tokens) {
490 RouteResolution::GlobalCommand {
491 name,
492 remaining_tokens,
493 } => {
494 assert_eq!(name, "help");
495 assert!(remaining_tokens.is_empty());
496 }
497 other => panic!("expected GlobalCommand, got {:?}", other),
498 }
499 }
500
501 #[test]
505 fn test_resolve_global_version() {
506 let tree = test_tree();
507 let tokens = vec![pos("version")];
508 match tree.resolve(&tokens) {
509 RouteResolution::GlobalCommand {
510 name,
511 remaining_tokens,
512 } => {
513 assert_eq!(name, "version");
514 assert!(remaining_tokens.is_empty());
515 }
516 other => panic!("expected GlobalCommand, got {:?}", other),
517 }
518 }
519
520 #[test]
524 fn test_resolve_partial_domain() {
525 let tree = test_tree();
526 let tokens = vec![pos("data")];
527 match tree.resolve(&tokens) {
528 RouteResolution::PartialDomain {
529 domain,
530 remaining_tokens,
531 } => {
532 assert_eq!(domain, "data");
533 assert!(remaining_tokens.is_empty());
534 }
535 other => panic!("expected PartialDomain, got {:?}", other),
536 }
537 }
538
539 #[test]
543 fn test_resolve_partial_resource() {
544 let tree = test_tree();
545 let tokens = vec![pos("data"), pos("collection")];
546 match tree.resolve(&tokens) {
547 RouteResolution::PartialResource {
548 domain,
549 resource,
550 remaining_tokens,
551 } => {
552 assert_eq!(domain, "data");
553 assert_eq!(resource, "collection");
554 assert!(remaining_tokens.is_empty());
555 }
556 other => panic!("expected PartialResource, got {:?}", other),
557 }
558 }
559
560 #[test]
564 fn test_resolve_unknown_domain() {
565 let tree = test_tree();
566 let tokens = vec![pos("daat"), pos("collection"), pos("list")];
567 match tree.resolve(&tokens) {
568 RouteResolution::Unknown {
569 tokens: toks,
570 suggestions,
571 } => {
572 assert_eq!(toks, vec!["daat", "collection", "list"]);
573 assert!(suggestions.contains(&"data".to_string()));
574 }
575 other => panic!("expected Unknown, got {:?}", other),
576 }
577 }
578
579 #[test]
583 fn test_resolve_compat_translation() {
584 let tree = test_tree();
585 let tokens = vec![pos("data"), pos("list"), pos("collection")];
587 match tree.resolve(&tokens) {
588 RouteResolution::Resolved { path, .. } => {
589 assert_eq!(path.domain, "data");
590 assert_eq!(path.resource.as_deref(), Some("collection"));
591 assert_eq!(path.verb.as_deref(), Some("list"));
592 }
593 other => panic!("expected Resolved via compat swap, got {:?}", other),
594 }
595 }
596
597 #[test]
601 fn test_resolve_with_remaining_tokens() {
602 let tree = test_tree();
603 let tokens = vec![
604 pos("server"),
605 pos("grpc"),
606 pos("start"),
607 pos("--path"),
608 long_flag("bind"),
609 pos("0.0.0.0:6380"),
610 ];
611 match tree.resolve(&tokens) {
612 RouteResolution::Resolved {
613 path,
614 remaining_tokens,
615 } => {
616 assert_eq!(path.domain, "server");
617 assert_eq!(path.resource.as_deref(), Some("grpc"));
618 assert_eq!(path.verb.as_deref(), Some("start"));
619 assert_eq!(remaining_tokens.len(), 3);
621 assert_eq!(remaining_tokens[0], pos("--path"));
622 }
623 other => panic!("expected Resolved, got {:?}", other),
624 }
625 }
626
627 #[test]
631 fn test_domains_and_resources_listing() {
632 let tree = test_tree();
633 let domains = tree.domains();
634 assert!(domains.contains(&"data"));
635 assert!(domains.contains(&"server"));
636 assert!(domains.contains(&"index"));
637 assert!(domains.contains(&"graph"));
638
639 let resources = tree.resources("server");
640 assert!(resources.contains(&"grpc"));
641 assert!(resources.contains(&"http"));
642
643 let verbs = tree.verbs("data", "collection");
644 assert!(verbs.contains(&"list"));
645 assert!(verbs.contains(&"create"));
646
647 assert!(tree.resources("fake").is_empty());
649 assert!(tree.verbs("fake", "foo").is_empty());
650 }
651}