Skip to main content

reddb_server/cli/
mod.rs

1/// RedDB CLI argument parser.
2///
3/// Schema-driven CLI with tokenizer, router, help generation, and shell
4/// completion. Self-contained -- no external dependencies on config or
5/// storage layers.
6pub mod bootstrap;
7pub mod bootstrap_manifest;
8pub mod commands;
9pub mod complete;
10pub mod error;
11pub mod help;
12pub mod router;
13pub mod schema;
14pub mod token;
15pub mod types;
16
17use std::collections::HashMap;
18
19/// Output format for CLI results.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum OutputFormat {
22    /// Human-readable colorized output (default)
23    #[default]
24    Human,
25    /// JSON output for automation/scripting
26    Json,
27    /// YAML output for configuration
28    Yaml,
29}
30
31impl OutputFormat {
32    pub fn from_str(s: &str) -> Option<Self> {
33        match s.to_lowercase().as_str() {
34            "human" | "h" | "text" => Some(OutputFormat::Human),
35            "json" | "j" => Some(OutputFormat::Json),
36            "yaml" | "yml" | "y" => Some(OutputFormat::Yaml),
37            _ => None,
38        }
39    }
40
41    pub fn as_str(&self) -> &str {
42        match self {
43            OutputFormat::Human => "human",
44            OutputFormat::Json => "json",
45            OutputFormat::Yaml => "yaml",
46        }
47    }
48}
49
50/// CLI execution context after parsing.
51///
52/// Holds the parsed command components and provides ergonomic helpers
53/// for flag lookup, output format detection, etc.
54#[derive(Debug, Clone, Default)]
55pub struct CliContext {
56    /// Full argument vector after `red`
57    pub raw: Vec<String>,
58    /// Primary command (e.g. "server", "query", "health")
59    pub domain: Option<String>,
60    /// Resource within the domain (e.g. collection name)
61    pub resource: Option<String>,
62    /// Verb or action to perform
63    pub verb: Option<String>,
64    /// Optional target (id, query string, etc.)
65    pub target: Option<String>,
66    /// Additional positional arguments beyond the target
67    pub args: Vec<String>,
68    /// Parsed flags (`--flag=value`, `-f value`, etc.)
69    pub flags: HashMap<String, String>,
70}
71
72impl CliContext {
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Get a flag value by name.
78    pub fn get_flag(&self, key: &str) -> Option<String> {
79        self.flags.get(key).cloned()
80    }
81
82    /// Check if a flag is set.
83    pub fn has_flag(&self, key: &str) -> bool {
84        self.flags.contains_key(key)
85    }
86
87    /// Get a flag value or return a default.
88    pub fn get_flag_or(&self, key: &str, default: &str) -> String {
89        self.get_flag(key).unwrap_or_else(|| default.to_string())
90    }
91
92    /// Get the domain only (first positional).
93    pub fn domain_only(&self) -> Option<&str> {
94        self.domain.as_deref()
95    }
96
97    /// Check if JSON output was explicitly requested.
98    pub fn wants_json(&self) -> bool {
99        self.has_flag("json") || self.has_flag("j")
100    }
101
102    /// Check if machine-readable output was requested (JSON or YAML).
103    pub fn wants_machine_output(&self) -> bool {
104        self.get_output_format() != OutputFormat::Human
105    }
106
107    /// Get the output format from flags or default to human.
108    pub fn get_output_format(&self) -> OutputFormat {
109        if self.wants_json() {
110            return OutputFormat::Json;
111        }
112
113        // Check both --output/-o and --format
114        let format_str = self
115            .get_flag("output")
116            .or_else(|| self.get_flag("o"))
117            .or_else(|| self.get_flag("format"));
118
119        if let Some(format_str) = format_str {
120            OutputFormat::from_str(&format_str).unwrap_or_default()
121        } else {
122            OutputFormat::default()
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_cli_context_default() {
133        let ctx = CliContext::default();
134        assert!(ctx.raw.is_empty());
135        assert!(ctx.domain.is_none());
136        assert!(ctx.resource.is_none());
137        assert!(ctx.verb.is_none());
138        assert!(ctx.target.is_none());
139        assert!(ctx.args.is_empty());
140        assert!(ctx.flags.is_empty());
141    }
142
143    #[test]
144    fn test_cli_context_new() {
145        let ctx = CliContext::new();
146        assert!(ctx.raw.is_empty());
147        assert!(ctx.domain.is_none());
148    }
149
150    #[test]
151    fn test_get_flag_from_cli() {
152        let mut ctx = CliContext::new();
153        ctx.flags.insert("path".to_string(), "/data".to_string());
154        ctx.flags
155            .insert("bind".to_string(), "0.0.0.0:6380".to_string());
156
157        assert_eq!(ctx.get_flag("path"), Some("/data".to_string()));
158        assert_eq!(ctx.get_flag("bind"), Some("0.0.0.0:6380".to_string()));
159        assert_eq!(ctx.get_flag("nonexistent"), None);
160    }
161
162    #[test]
163    fn test_has_flag() {
164        let mut ctx = CliContext::new();
165        ctx.flags.insert("verbose".to_string(), "true".to_string());
166        ctx.flags.insert("quiet".to_string(), "".to_string());
167
168        assert!(ctx.has_flag("verbose"));
169        assert!(ctx.has_flag("quiet"));
170        assert!(!ctx.has_flag("nonexistent"));
171    }
172
173    #[test]
174    fn test_get_flag_or() {
175        let mut ctx = CliContext::new();
176        ctx.flags.insert("path".to_string(), "/data".to_string());
177
178        assert_eq!(ctx.get_flag_or("path", "/default"), "/data");
179        assert_eq!(ctx.get_flag_or("bind", "0.0.0.0:6380"), "0.0.0.0:6380");
180    }
181
182    #[test]
183    fn test_domain_only() {
184        let mut ctx = CliContext::new();
185        assert_eq!(ctx.domain_only(), None);
186
187        ctx.domain = Some("server".to_string());
188        assert_eq!(ctx.domain_only(), Some("server"));
189    }
190
191    #[test]
192    fn test_get_output_format_default() {
193        let ctx = CliContext::new();
194        let format = ctx.get_output_format();
195        assert_eq!(format, OutputFormat::default());
196    }
197
198    #[test]
199    fn test_get_output_format_from_output_flag() {
200        let mut ctx = CliContext::new();
201        ctx.flags.insert("output".to_string(), "json".to_string());
202        let format = ctx.get_output_format();
203        assert_eq!(format, OutputFormat::Json);
204    }
205
206    #[test]
207    fn test_get_output_format_from_o_flag() {
208        let mut ctx = CliContext::new();
209        ctx.flags.insert("o".to_string(), "json".to_string());
210        let format = ctx.get_output_format();
211        assert_eq!(format, OutputFormat::Json);
212    }
213
214    #[test]
215    fn test_get_output_format_from_format_flag() {
216        let mut ctx = CliContext::new();
217        ctx.flags.insert("format".to_string(), "json".to_string());
218        let format = ctx.get_output_format();
219        assert_eq!(format, OutputFormat::Json);
220    }
221
222    #[test]
223    fn test_get_output_format_from_json_flag() {
224        let mut ctx = CliContext::new();
225        ctx.flags.insert("json".to_string(), "true".to_string());
226        let format = ctx.get_output_format();
227        assert_eq!(format, OutputFormat::Json);
228    }
229
230    #[test]
231    fn test_wants_json_from_short_flag() {
232        let mut ctx = CliContext::new();
233        ctx.flags.insert("j".to_string(), "true".to_string());
234        assert!(ctx.wants_json());
235        assert_eq!(ctx.get_output_format(), OutputFormat::Json);
236    }
237
238    #[test]
239    fn test_wants_machine_output_from_yaml_flag() {
240        let mut ctx = CliContext::new();
241        ctx.flags.insert("output".to_string(), "yaml".to_string());
242        assert!(ctx.wants_machine_output());
243    }
244
245    #[test]
246    fn test_get_output_format_priority() {
247        let mut ctx = CliContext::new();
248        // --output has priority over -o and --format
249        ctx.flags.insert("output".to_string(), "json".to_string());
250        ctx.flags.insert("o".to_string(), "text".to_string());
251        ctx.flags.insert("format".to_string(), "csv".to_string());
252        let format = ctx.get_output_format();
253        assert_eq!(format, OutputFormat::Json);
254    }
255
256    #[test]
257    fn test_cli_context_with_full_command() {
258        let mut ctx = CliContext::new();
259        ctx.raw = vec![
260            "server".to_string(),
261            "--path".to_string(),
262            "/data".to_string(),
263            "--bind".to_string(),
264            "0.0.0.0:6380".to_string(),
265        ];
266        ctx.domain = Some("server".to_string());
267        ctx.flags.insert("path".to_string(), "/data".to_string());
268        ctx.flags
269            .insert("bind".to_string(), "0.0.0.0:6380".to_string());
270
271        assert_eq!(ctx.domain_only(), Some("server"));
272        assert_eq!(ctx.get_flag("path"), Some("/data".to_string()));
273        assert_eq!(ctx.get_flag_or("bind", "localhost:6380"), "0.0.0.0:6380");
274        assert!(ctx.has_flag("path"));
275        assert!(!ctx.has_flag("verbose"));
276    }
277
278    #[test]
279    fn test_cli_context_with_args() {
280        let mut ctx = CliContext::new();
281        ctx.args = vec!["arg1".to_string(), "arg2".to_string(), "arg3".to_string()];
282
283        assert_eq!(ctx.args.len(), 3);
284        assert_eq!(ctx.args[0], "arg1");
285        assert_eq!(ctx.args[1], "arg2");
286        assert_eq!(ctx.args[2], "arg3");
287    }
288
289    #[test]
290    fn test_cli_context_clone() {
291        let mut ctx = CliContext::new();
292        ctx.domain = Some("server".to_string());
293        ctx.flags
294            .insert("bind".to_string(), "0.0.0.0:6380".to_string());
295
296        let ctx2 = ctx.clone();
297        assert_eq!(ctx2.domain, ctx.domain);
298        assert_eq!(ctx2.get_flag("bind"), ctx.get_flag("bind"));
299    }
300
301    #[test]
302    fn test_cli_context_debug() {
303        let ctx = CliContext::new();
304        let debug_str = format!("{:?}", ctx);
305        assert!(debug_str.contains("CliContext"));
306        assert!(debug_str.contains("raw"));
307        assert!(debug_str.contains("domain"));
308    }
309
310    #[test]
311    fn test_output_format_from_str() {
312        assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
313        assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json));
314        assert_eq!(OutputFormat::from_str("yaml"), Some(OutputFormat::Yaml));
315        assert_eq!(OutputFormat::from_str("yml"), Some(OutputFormat::Yaml));
316        assert_eq!(OutputFormat::from_str("human"), Some(OutputFormat::Human));
317        assert_eq!(OutputFormat::from_str("text"), Some(OutputFormat::Human));
318        assert_eq!(OutputFormat::from_str("xml"), None);
319    }
320
321    #[test]
322    fn test_output_format_as_str() {
323        assert_eq!(OutputFormat::Human.as_str(), "human");
324        assert_eq!(OutputFormat::Json.as_str(), "json");
325        assert_eq!(OutputFormat::Yaml.as_str(), "yaml");
326    }
327}