Skip to main content

reddb_server/cli/
types.rs

1use std::collections::HashMap;
2
3/// Value type for flag schema
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ValueType {
6    Bool,
7    String,
8    Integer,
9    Float,
10    Count, // -vvv => 3
11}
12
13/// Parsed flag value
14#[derive(Debug, Clone, PartialEq)]
15pub enum FlagValue {
16    Bool(bool),
17    Str(String),
18    Int(i64),
19    Float(f64),
20    Count(u32),
21}
22
23impl FlagValue {
24    pub fn as_str_value(&self) -> String {
25        match self {
26            FlagValue::Bool(b) => b.to_string(),
27            FlagValue::Str(s) => s.clone(),
28            FlagValue::Int(n) => n.to_string(),
29            FlagValue::Float(f) => f.to_string(),
30            FlagValue::Count(n) => n.to_string(),
31        }
32    }
33
34    pub fn is_truthy(&self) -> bool {
35        match self {
36            FlagValue::Bool(b) => *b,
37            FlagValue::Str(s) => !s.is_empty(),
38            FlagValue::Int(n) => *n != 0,
39            FlagValue::Float(f) => *f != 0.0,
40            FlagValue::Count(n) => *n > 0,
41        }
42    }
43}
44
45impl std::fmt::Display for FlagValue {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.as_str_value())
48    }
49}
50
51/// Schema for a single flag/option
52#[derive(Debug, Clone)]
53pub struct FlagSchema {
54    pub long: String,
55    pub short: Option<char>,
56    pub description: String,
57    pub value_type: ValueType,
58    pub expects_value: bool,
59    pub default: Option<String>,
60    pub choices: Option<Vec<String>>,
61    pub required: bool,
62    pub hidden: bool,
63}
64
65impl FlagSchema {
66    pub fn new(long: &str) -> Self {
67        Self {
68            long: long.to_string(),
69            short: None,
70            description: String::new(),
71            value_type: ValueType::String,
72            expects_value: true,
73            default: None,
74            choices: None,
75            required: false,
76            hidden: false,
77        }
78    }
79
80    pub fn boolean(long: &str) -> Self {
81        Self {
82            long: long.to_string(),
83            short: None,
84            description: String::new(),
85            value_type: ValueType::Bool,
86            expects_value: false,
87            default: None,
88            choices: None,
89            required: false,
90            hidden: false,
91        }
92    }
93
94    pub fn with_short(mut self, short: char) -> Self {
95        self.short = Some(short);
96        self
97    }
98
99    pub fn with_description(mut self, desc: &str) -> Self {
100        self.description = desc.to_string();
101        self
102    }
103
104    pub fn with_default(mut self, default: &str) -> Self {
105        self.default = Some(default.to_string());
106        self
107    }
108
109    pub fn with_choices(mut self, choices: &[&str]) -> Self {
110        self.choices = Some(choices.iter().map(|s| s.to_string()).collect());
111        self
112    }
113
114    pub fn required(mut self) -> Self {
115        self.required = true;
116        self
117    }
118
119    pub fn hidden(mut self) -> Self {
120        self.hidden = true;
121        self
122    }
123}
124
125/// Resolved command path
126#[derive(Debug, Clone, Default)]
127pub struct CommandPath {
128    pub domain: String,
129    pub resource: Option<String>,
130    pub verb: Option<String>,
131}
132
133impl CommandPath {
134    pub fn is_complete(&self) -> bool {
135        self.resource.is_some() && self.verb.is_some()
136    }
137
138    pub fn canonical(&self) -> String {
139        let mut parts = vec![self.domain.clone()];
140        if let Some(ref r) = self.resource {
141            parts.push(r.clone());
142        }
143        if let Some(ref v) = self.verb {
144            parts.push(v.clone());
145        }
146        parts.join("/")
147    }
148}
149
150/// Result of parsing a complete command line
151#[derive(Debug, Clone)]
152pub struct ParsedCommand {
153    pub path: CommandPath,
154    pub target: Option<String>,
155    pub positional_args: Vec<String>,
156    pub flags: HashMap<String, FlagValue>,
157    pub raw: Vec<String>,
158}
159
160impl ParsedCommand {
161    pub fn new() -> Self {
162        Self {
163            path: CommandPath::default(),
164            target: None,
165            positional_args: Vec::new(),
166            flags: HashMap::new(),
167            raw: Vec::new(),
168        }
169    }
170
171    pub fn get_flag(&self, name: &str) -> Option<&str> {
172        match self.flags.get(name)? {
173            FlagValue::Str(s) => Some(s.as_str()),
174            _ => None,
175        }
176    }
177
178    pub fn has_flag(&self, name: &str) -> bool {
179        self.flags.get(name).is_some_and(|v| v.is_truthy())
180    }
181}
182
183/// Global flags that apply to all RedDB commands
184pub fn global_flags() -> Vec<FlagSchema> {
185    vec![
186        FlagSchema::boolean("json")
187            .with_short('j')
188            .with_description("Force JSON output"),
189        FlagSchema::boolean("help")
190            .with_short('h')
191            .with_description("Show help"),
192        FlagSchema::boolean("version").with_description("Show version"),
193        FlagSchema::new("output")
194            .with_short('o')
195            .with_description("Output format")
196            .with_choices(&["text", "json", "yaml"]),
197        FlagSchema::boolean("no-color").with_description("Disable colors"),
198        FlagSchema::boolean("verbose")
199            .with_short('v')
200            .with_description("Verbose output"),
201    ]
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_flag_schema_builder() {
210        let flag = FlagSchema::new("output")
211            .with_short('o')
212            .with_description("Output format")
213            .with_default("text")
214            .with_choices(&["text", "json"])
215            .required();
216
217        assert_eq!(flag.long, "output");
218        assert_eq!(flag.short, Some('o'));
219        assert_eq!(flag.description, "Output format");
220        assert_eq!(flag.default, Some("text".to_string()));
221        assert_eq!(
222            flag.choices,
223            Some(vec!["text".to_string(), "json".to_string()])
224        );
225        assert!(flag.required);
226        assert!(flag.expects_value);
227        assert_eq!(flag.value_type, ValueType::String);
228
229        let bool_flag = FlagSchema::boolean("verbose").hidden();
230        assert_eq!(bool_flag.value_type, ValueType::Bool);
231        assert!(!bool_flag.expects_value);
232        assert!(bool_flag.hidden);
233    }
234
235    #[test]
236    fn test_flag_value_as_str() {
237        assert_eq!(FlagValue::Bool(true).as_str_value(), "true");
238        assert_eq!(FlagValue::Bool(false).as_str_value(), "false");
239        assert_eq!(FlagValue::Str("hello".into()).as_str_value(), "hello");
240        assert_eq!(FlagValue::Int(42).as_str_value(), "42");
241        assert_eq!(FlagValue::Float(2.5).as_str_value(), "2.5");
242        assert_eq!(FlagValue::Count(3).as_str_value(), "3");
243    }
244
245    #[test]
246    fn test_flag_value_is_truthy() {
247        assert!(FlagValue::Bool(true).is_truthy());
248        assert!(!FlagValue::Bool(false).is_truthy());
249        assert!(FlagValue::Str("yes".into()).is_truthy());
250        assert!(!FlagValue::Str(String::new()).is_truthy());
251        assert!(FlagValue::Int(1).is_truthy());
252        assert!(!FlagValue::Int(0).is_truthy());
253        assert!(FlagValue::Float(0.1).is_truthy());
254        assert!(!FlagValue::Float(0.0).is_truthy());
255        assert!(FlagValue::Count(1).is_truthy());
256        assert!(!FlagValue::Count(0).is_truthy());
257    }
258
259    #[test]
260    fn test_command_path_canonical() {
261        let full = CommandPath {
262            domain: "server".into(),
263            resource: Some("grpc".into()),
264            verb: Some("start".into()),
265        };
266        assert_eq!(full.canonical(), "server/grpc/start");
267
268        let partial = CommandPath {
269            domain: "query".into(),
270            resource: Some("sql".into()),
271            verb: None,
272        };
273        assert_eq!(partial.canonical(), "query/sql");
274
275        let domain_only = CommandPath {
276            domain: "health".into(),
277            resource: None,
278            verb: None,
279        };
280        assert_eq!(domain_only.canonical(), "health");
281    }
282
283    #[test]
284    fn test_command_path_is_complete() {
285        let complete = CommandPath {
286            domain: "server".into(),
287            resource: Some("grpc".into()),
288            verb: Some("start".into()),
289        };
290        assert!(complete.is_complete());
291
292        let incomplete = CommandPath {
293            domain: "server".into(),
294            resource: Some("grpc".into()),
295            verb: None,
296        };
297        assert!(!incomplete.is_complete());
298
299        let minimal = CommandPath {
300            domain: "health".into(),
301            resource: None,
302            verb: None,
303        };
304        assert!(!minimal.is_complete());
305    }
306
307    #[test]
308    fn test_parsed_command_get_flag() {
309        let mut cmd = ParsedCommand::new();
310        cmd.flags
311            .insert("output".into(), FlagValue::Str("json".into()));
312        cmd.flags.insert("verbose".into(), FlagValue::Bool(true));
313
314        assert_eq!(cmd.get_flag("output"), Some("json"));
315        assert_eq!(cmd.get_flag("verbose"), None); // not a Str variant
316        assert_eq!(cmd.get_flag("missing"), None);
317    }
318
319    #[test]
320    fn test_parsed_command_has_flag() {
321        let mut cmd = ParsedCommand::new();
322        cmd.flags.insert("verbose".into(), FlagValue::Bool(true));
323        cmd.flags.insert("quiet".into(), FlagValue::Bool(false));
324        cmd.flags.insert("count".into(), FlagValue::Count(3));
325
326        assert!(cmd.has_flag("verbose"));
327        assert!(!cmd.has_flag("quiet"));
328        assert!(cmd.has_flag("count"));
329        assert!(!cmd.has_flag("missing"));
330    }
331
332    #[test]
333    fn test_global_flags_defined() {
334        let flags = global_flags();
335        assert!(flags.len() >= 6);
336
337        let names: Vec<&str> = flags.iter().map(|f| f.long.as_str()).collect();
338        assert!(names.contains(&"json"));
339        assert!(names.contains(&"help"));
340        assert!(names.contains(&"version"));
341        assert!(names.contains(&"output"));
342        assert!(names.contains(&"no-color"));
343        assert!(names.contains(&"verbose"));
344
345        let output = flags.iter().find(|f| f.long == "output").unwrap();
346        assert!(output.expects_value);
347        assert!(output.choices.is_some());
348
349        let help = flags.iter().find(|f| f.long == "help").unwrap();
350        assert_eq!(help.short, Some('h'));
351        assert!(!help.expects_value);
352    }
353}