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