Skip to main content

reddb_server/cli/
router.rs

1/// Command router: resolves domain/resource/verb from positional tokens.
2///
3/// Builds a RouteTree from registry data and resolves a token stream into
4/// one of several outcomes: fully resolved command, global command,
5/// partial match, or unknown with suggestions.
6use std::collections::{HashMap, HashSet};
7
8use super::error::suggest;
9use super::token::Token;
10use super::types::CommandPath;
11
12/// Global commands that bypass domain/resource/verb routing.
13const GLOBAL_COMMANDS: &[&str] = &["help", "version", "commands"];
14
15/// Result of route resolution.
16#[derive(Debug)]
17pub enum RouteResolution {
18    /// Fully resolved: domain + resource + verb.
19    Resolved {
20        path: CommandPath,
21        remaining_tokens: Vec<Token>,
22    },
23    /// Global command: help, version, commands.
24    GlobalCommand {
25        name: String,
26        remaining_tokens: Vec<Token>,
27    },
28    /// Partial: only domain found (no resource given).
29    PartialDomain {
30        domain: String,
31        remaining_tokens: Vec<Token>,
32    },
33    /// Partial: domain + resource found but no verb.
34    PartialResource {
35        domain: String,
36        resource: String,
37        remaining_tokens: Vec<Token>,
38    },
39    /// Nothing matched.
40    Unknown {
41        tokens: Vec<String>,
42        suggestions: Vec<String>,
43    },
44}
45
46// ------------------------------------------------------------------
47// Internal tree structures
48// ------------------------------------------------------------------
49
50struct 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
60/// Domain/resource/verb tree built from Command trait implementations.
61pub struct RouteTree {
62    domains: HashMap<String, DomainEntry>,
63    aliases: HashMap<String, String>,
64}
65
66impl RouteTree {
67    /// Build from command registry data.
68    ///
69    /// * `domains`   - `(domain_name, domain_aliases)`
70    /// * `resources` - `(domain, resource_name, resource_aliases)`
71    /// * `verbs`     - `(domain, resource, verb_name, verb_aliases)`
72    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        // Register domains and their aliases.
81        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        // Register resources and their aliases within their domain.
94        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        // Register verbs and their aliases within their domain/resource.
114        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    /// Resolve tokens into a route.
143    pub fn resolve(&self, tokens: &[Token]) -> RouteResolution {
144        // 1. Collect leading positionals (stop at first flag token).
145        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        // Remaining = everything after positionals consumed for routing.
159        let build_remaining = |consumed: usize| -> Vec<Token> {
160            let start = if consumed < positionals.len() {
161                // Unconsumed positionals + all remaining tokens.
162                consumed
163            } else {
164                positionals.len()
165            };
166            let mut remaining = Vec::new();
167            // Re-emit unconsumed positionals.
168            for &p in &positionals[start..] {
169                remaining.push(Token::Positional(p.to_string()));
170            }
171            // Append everything from the first non-positional onward.
172            let tail_start = first_non_pos_idx.unwrap_or(tokens.len());
173            remaining.extend_from_slice(&tokens[tail_start..]);
174            remaining
175        };
176
177        // 2. If empty: nothing to route.
178        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        // 3. Check global commands.
188        if GLOBAL_COMMANDS.contains(&first) {
189            return RouteResolution::GlobalCommand {
190                name: first.to_string(),
191                remaining_tokens: build_remaining(1),
192            };
193        }
194
195        // 4. Resolve domain (canonical or alias).
196        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            // Unknown domain -- generate suggestions.
202            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        // 5. Only domain given.
214        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        // 6. Resolve resource (canonical or alias within domain).
224        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            // Before giving up, check compat translation (legacy: domain verb resource).
230            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            // Resource not found.
242            return RouteResolution::PartialDomain {
243                domain,
244                remaining_tokens: build_remaining(1),
245            };
246        };
247
248        // 7. Only domain + resource given.
249        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        // 8. Resolve verb (canonical or alias within resource).
264        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        // 9. Compat translation: try swapping positional[1] and positional[2].
287        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        // Verb not found but domain+resource valid.
298        RouteResolution::PartialResource {
299            domain,
300            resource,
301            remaining_tokens: build_remaining(2),
302        }
303    }
304
305    /// Get all canonical domain names.
306    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    /// Get canonical resource names for a domain.
313    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    /// Get canonical verb names for a domain/resource pair.
325    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    // ------------------------------------------------------------------
338    // Private helpers
339    // ------------------------------------------------------------------
340
341    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    /// Try the legacy order swap: `domain verb resource` -> `domain resource verb`.
348    /// Returns `Some(Resolved)` if the swap produces a valid route.
349    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        // Interpret first_token as verb, second_token as resource.
358        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// ==================================================================
384// Tests
385// ==================================================================
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::cli::token::Token;
391
392    /// Build a small tree for testing.
393    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    // ----------------------------------------------------------------
441    // 1. Full command resolution
442    // ----------------------------------------------------------------
443    #[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    // ----------------------------------------------------------------
462    // 2. Alias resolution
463    // ----------------------------------------------------------------
464    #[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    // ----------------------------------------------------------------
483    // 3. Global help
484    // ----------------------------------------------------------------
485    #[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    // ----------------------------------------------------------------
502    // 4. Global version
503    // ----------------------------------------------------------------
504    #[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    // ----------------------------------------------------------------
521    // 5. Partial domain
522    // ----------------------------------------------------------------
523    #[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    // ----------------------------------------------------------------
540    // 6. Partial resource
541    // ----------------------------------------------------------------
542    #[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    // ----------------------------------------------------------------
561    // 7. Unknown domain
562    // ----------------------------------------------------------------
563    #[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    // ----------------------------------------------------------------
580    // 8. Compat translation (legacy order: domain verb resource)
581    // ----------------------------------------------------------------
582    #[test]
583    fn test_resolve_compat_translation() {
584        let tree = test_tree();
585        // Legacy: red data list collection -> canonical: red data collection list
586        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    // ----------------------------------------------------------------
598    // 9. Remaining tokens pass-through
599    // ----------------------------------------------------------------
600    #[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                // remaining: "--path" positional, --bind flag, "0.0.0.0:6380" positional
620                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    // ----------------------------------------------------------------
628    // 10. domains() and resources() listing
629    // ----------------------------------------------------------------
630    #[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        // Non-existent domain returns empty.
648        assert!(tree.resources("fake").is_empty());
649        assert!(tree.verbs("fake", "foo").is_empty());
650    }
651}