Skip to main content

reddb_server/cli/
schema.rs

1/// Schema-driven flag parser.
2///
3/// Given a list of `FlagSchema` descriptors and a token stream from the
4/// lexer, validates flags, coerces values to the declared types, applies
5/// defaults, and collects all errors so they can be reported at once.
6use super::error::{suggest, ParseError};
7use super::token::Token;
8use super::types::{FlagSchema, FlagValue, ValueType};
9use std::collections::HashMap;
10
11/// Result of parsing tokens against a flag schema.
12pub struct SchemaResult {
13    pub flags: HashMap<String, FlagValue>,
14    pub positionals: Vec<String>,
15    pub errors: Vec<ParseError>,
16}
17
18/// Schema-driven flag parser.
19pub struct SchemaParser {
20    flags: Vec<FlagSchema>,
21}
22
23impl SchemaParser {
24    pub fn new(flags: Vec<FlagSchema>) -> Self {
25        Self { flags }
26    }
27
28    /// Parse tokens against the schema.
29    ///
30    /// Returns parsed flags (with defaults applied), remaining positionals,
31    /// and any validation errors. Errors are collected rather than thrown so
32    /// the caller can display all of them at once.
33    pub fn parse(&self, tokens: &[Token]) -> SchemaResult {
34        // Build lookup maps keyed by long name and short char.
35        let mut long_map: HashMap<&str, &FlagSchema> = HashMap::new();
36        let mut short_map: HashMap<char, &FlagSchema> = HashMap::new();
37
38        for schema in &self.flags {
39            long_map.insert(&schema.long, schema);
40            if let Some(ch) = schema.short {
41                short_map.insert(ch, schema);
42            }
43        }
44
45        // Collect all long names for suggestion generation.
46        let all_longs: Vec<&str> = self.flags.iter().map(|f| f.long.as_str()).collect();
47
48        // Initialize flags map with defaults from schema.
49        let mut flags: HashMap<String, FlagValue> = HashMap::new();
50        for schema in &self.flags {
51            if let Some(ref default) = schema.default {
52                if let Ok(val) = coerce_value(default, schema.value_type) {
53                    flags.insert(schema.long.clone(), val);
54                }
55            }
56        }
57
58        let mut positionals: Vec<String> = Vec::new();
59        let mut errors: Vec<ParseError> = Vec::new();
60        let mut i = 0;
61
62        while i < tokens.len() {
63            match &tokens[i] {
64                Token::LongFlag { name, value } => {
65                    // Handle --no-<name> boolean negation.
66                    if let Some(base) = name.strip_prefix("no-") {
67                        if let Some(schema) = long_map.get(base) {
68                            if schema.value_type == ValueType::Bool {
69                                flags.insert(base.to_string(), FlagValue::Bool(false));
70                                i += 1;
71                                continue;
72                            }
73                        }
74                    }
75
76                    match long_map.get(name.as_str()) {
77                        None => {
78                            let suggestions = suggest(name, &all_longs, 3)
79                                .into_iter()
80                                .map(|s| format!("--{}", s))
81                                .collect();
82                            errors.push(ParseError::UnknownFlag {
83                                flag: format!("--{}", name),
84                                suggestions,
85                            });
86                        }
87                        Some(schema) => {
88                            self.process_flag(
89                                schema,
90                                value,
91                                tokens,
92                                &mut i,
93                                &mut flags,
94                                &mut errors,
95                            );
96                        }
97                    }
98                    i += 1;
99                }
100
101                Token::ShortFlag { name, value } => {
102                    match short_map.get(name) {
103                        None => {
104                            // Build suggestions from short flags that exist.
105                            let all_shorts: Vec<&str> = self
106                                .flags
107                                .iter()
108                                .filter_map(|f| f.short.as_ref())
109                                .map(|_| "")
110                                .collect();
111                            let _ = all_shorts; // short flags are single chars, suggestion not very useful
112                            errors.push(ParseError::UnknownFlag {
113                                flag: format!("-{}", name),
114                                suggestions: vec![],
115                            });
116                        }
117                        Some(schema) => {
118                            let str_value = value.as_ref().map(|v| v.to_string());
119                            self.process_flag(
120                                schema,
121                                &str_value,
122                                tokens,
123                                &mut i,
124                                &mut flags,
125                                &mut errors,
126                            );
127                        }
128                    }
129                    i += 1;
130                }
131
132                Token::ShortCluster(chars) => {
133                    let last_idx = chars.len() - 1;
134                    for (ci, &ch) in chars.iter().enumerate() {
135                        match short_map.get(&ch) {
136                            None => {
137                                errors.push(ParseError::UnknownFlag {
138                                    flag: format!("-{}", ch),
139                                    suggestions: vec![],
140                                });
141                            }
142                            Some(schema) => {
143                                if ci < last_idx {
144                                    // All chars except the last must be boolean.
145                                    if schema.expects_value {
146                                        errors.push(ParseError::InvalidValue {
147                      flag: format!("-{}", ch),
148                      value: String::new(),
149                      expected_type: format_type(schema.value_type),
150                      reason: "flag requires a value and cannot appear in a cluster".to_string(),
151                    });
152                                    } else {
153                                        store_flag(&mut flags, schema, FlagValue::Bool(true));
154                                    }
155                                } else {
156                                    // Last char: may consume next positional if it expects a value.
157                                    if schema.expects_value {
158                                        if i + 1 < tokens.len() {
159                                            if let Token::Positional(ref val) = tokens[i + 1] {
160                                                match coerce_value(val, schema.value_type) {
161                                                    Ok(fv) => {
162                                                        store_flag(&mut flags, schema, fv);
163                                                    }
164                                                    Err(reason) => {
165                                                        errors.push(ParseError::InvalidValue {
166                                                            flag: format!("-{}", ch),
167                                                            value: val.clone(),
168                                                            expected_type: format_type(
169                                                                schema.value_type,
170                                                            ),
171                                                            reason,
172                                                        });
173                                                    }
174                                                }
175                                                i += 1; // consume the peeked positional
176                                            } else {
177                                                errors.push(ParseError::MissingFlagValue {
178                                                    flag: format!("-{}", ch),
179                                                    expected_type: format_type(schema.value_type),
180                                                });
181                                            }
182                                        } else {
183                                            errors.push(ParseError::MissingFlagValue {
184                                                flag: format!("-{}", ch),
185                                                expected_type: format_type(schema.value_type),
186                                            });
187                                        }
188                                    } else {
189                                        store_flag(&mut flags, schema, FlagValue::Bool(true));
190                                    }
191                                }
192                            }
193                        }
194                    }
195                    i += 1;
196                }
197
198                Token::Positional(s) => {
199                    positionals.push(s.clone());
200                    i += 1;
201                }
202
203                Token::EndOfOptions => {
204                    // Everything after becomes positional.
205                    i += 1;
206                    while i < tokens.len() {
207                        match &tokens[i] {
208                            Token::Positional(s) => positionals.push(s.clone()),
209                            // After EndOfOptions the tokenizer already wraps everything as Positional,
210                            // but handle other variants defensively.
211                            Token::LongFlag { name, value } => {
212                                let mut repr = format!("--{}", name);
213                                if let Some(v) = value {
214                                    repr.push('=');
215                                    repr.push_str(v);
216                                }
217                                positionals.push(repr);
218                            }
219                            Token::ShortFlag { name, value } => {
220                                let mut repr = format!("-{}", name);
221                                if let Some(v) = value {
222                                    repr.push('=');
223                                    repr.push_str(v);
224                                }
225                                positionals.push(repr);
226                            }
227                            Token::ShortCluster(chars) => {
228                                let s: String = chars.iter().collect();
229                                positionals.push(format!("-{}", s));
230                            }
231                            Token::EndOfOptions => {
232                                positionals.push("--".to_string());
233                            }
234                        }
235                        i += 1;
236                    }
237                }
238            }
239        }
240
241        // Post-parse validation: required flags.
242        for schema in &self.flags {
243            if schema.required && !flags.contains_key(&schema.long) {
244                errors.push(ParseError::MissingRequired {
245                    flag: format!("--{}", schema.long),
246                });
247            }
248        }
249
250        // Post-parse validation: choices.
251        for schema in &self.flags {
252            if let Some(ref choices) = schema.choices {
253                if let Some(val) = flags.get(&schema.long) {
254                    let s = val.as_str_value();
255                    if !choices.contains(&s) {
256                        errors.push(ParseError::InvalidChoice {
257                            flag: format!("--{}", schema.long),
258                            value: s,
259                            allowed: choices.clone(),
260                        });
261                    }
262                }
263            }
264        }
265
266        SchemaResult {
267            flags,
268            positionals,
269            errors,
270        }
271    }
272
273    /// Process a single flag (long or short) that matched a schema entry.
274    /// Handles value consumption, peek-ahead, and boolean rejection.
275    fn process_flag(
276        &self,
277        schema: &FlagSchema,
278        value: &Option<String>,
279        tokens: &[Token],
280        i: &mut usize,
281        flags: &mut HashMap<String, FlagValue>,
282        errors: &mut Vec<ParseError>,
283    ) {
284        let flag_display = if let Some(ch) = schema.short {
285            if schema.long.is_empty() {
286                format!("-{}", ch)
287            } else {
288                format!("--{}", schema.long)
289            }
290        } else {
291            format!("--{}", schema.long)
292        };
293
294        if schema.expects_value {
295            match value {
296                Some(raw) => match coerce_value(raw, schema.value_type) {
297                    Ok(fv) => {
298                        store_flag(flags, schema, fv);
299                    }
300                    Err(reason) => {
301                        errors.push(ParseError::InvalidValue {
302                            flag: flag_display,
303                            value: raw.clone(),
304                            expected_type: format_type(schema.value_type),
305                            reason,
306                        });
307                    }
308                },
309                None => {
310                    // Peek next token for the value.
311                    if *i + 1 < tokens.len() {
312                        if let Token::Positional(ref val) = tokens[*i + 1] {
313                            match coerce_value(val, schema.value_type) {
314                                Ok(fv) => {
315                                    store_flag(flags, schema, fv);
316                                }
317                                Err(reason) => {
318                                    errors.push(ParseError::InvalidValue {
319                                        flag: flag_display,
320                                        value: val.clone(),
321                                        expected_type: format_type(schema.value_type),
322                                        reason,
323                                    });
324                                }
325                            }
326                            *i += 1; // consume the peeked token
327                            return;
328                        }
329                    }
330                    errors.push(ParseError::MissingFlagValue {
331                        flag: flag_display,
332                        expected_type: format_type(schema.value_type),
333                    });
334                }
335            }
336        } else {
337            // Boolean flag -- must not have an inline value.
338            if let Some(inline_value) = value {
339                errors.push(ParseError::InvalidValue {
340                    flag: flag_display,
341                    value: inline_value.clone(),
342                    expected_type: "bool".to_string(),
343                    reason: "boolean flags do not accept a value".to_string(),
344                });
345            } else {
346                store_flag(flags, schema, FlagValue::Bool(true));
347            }
348        }
349    }
350}
351
352/// Store a flag value, handling repeated-flag semantics.
353fn store_flag(flags: &mut HashMap<String, FlagValue>, schema: &FlagSchema, value: FlagValue) {
354    if schema.value_type == ValueType::Count {
355        let current = flags.get(&schema.long).and_then(|v| {
356            if let FlagValue::Count(n) = v {
357                Some(*n)
358            } else {
359                None
360            }
361        });
362        flags.insert(
363            schema.long.clone(),
364            FlagValue::Count(current.unwrap_or(0) + 1),
365        );
366    } else {
367        // Last value wins for all other types.
368        flags.insert(schema.long.clone(), value);
369    }
370}
371
372/// Coerce a raw string to the declared value type.
373fn coerce_value(raw: &str, value_type: ValueType) -> Result<FlagValue, String> {
374    match value_type {
375        ValueType::String => Ok(FlagValue::Str(raw.to_string())),
376        ValueType::Bool => match raw.to_lowercase().as_str() {
377            "true" | "yes" | "1" | "on" => Ok(FlagValue::Bool(true)),
378            "false" | "no" | "0" | "off" => Ok(FlagValue::Bool(false)),
379            _ => Err(format!("expected boolean, got '{}'", raw)),
380        },
381        ValueType::Integer => raw
382            .parse::<i64>()
383            .map(FlagValue::Int)
384            .map_err(|_| format!("expected integer, got '{}'", raw)),
385        ValueType::Float => raw
386            .parse::<f64>()
387            .map(FlagValue::Float)
388            .map_err(|_| format!("expected number, got '{}'", raw)),
389        ValueType::Count => Ok(FlagValue::Count(1)),
390    }
391}
392
393/// Human-readable name for a value type (used in error messages).
394fn format_type(vt: ValueType) -> String {
395    match vt {
396        ValueType::String => "string".to_string(),
397        ValueType::Bool => "bool".to_string(),
398        ValueType::Integer => "integer".to_string(),
399        ValueType::Float => "float".to_string(),
400        ValueType::Count => "count".to_string(),
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::cli::token::tokenize;
408
409    fn args(v: &[&str]) -> Vec<String> {
410        v.iter().map(|a| a.to_string()).collect()
411    }
412
413    fn make_parser(schemas: Vec<FlagSchema>) -> SchemaParser {
414        SchemaParser::new(schemas)
415    }
416
417    #[test]
418    fn test_parse_long_flag_bool() {
419        let parser = make_parser(vec![FlagSchema::boolean("verbose").with_short('v')]);
420        let tokens = tokenize(&args(&["--verbose"]));
421        let result = parser.parse(&tokens);
422
423        assert!(result.errors.is_empty());
424        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
425    }
426
427    #[test]
428    fn test_parse_long_flag_with_value() {
429        let parser = make_parser(vec![FlagSchema::new("output").with_short('o')]);
430        let tokens = tokenize(&args(&["--output", "json"]));
431        let result = parser.parse(&tokens);
432
433        assert!(result.errors.is_empty());
434        assert_eq!(
435            result.flags.get("output"),
436            Some(&FlagValue::Str("json".to_string()))
437        );
438        assert!(result.positionals.is_empty());
439    }
440
441    #[test]
442    fn test_parse_long_flag_equals() {
443        let parser = make_parser(vec![FlagSchema::new("output")]);
444        let tokens = tokenize(&args(&["--output=json"]));
445        let result = parser.parse(&tokens);
446
447        assert!(result.errors.is_empty());
448        assert_eq!(
449            result.flags.get("output"),
450            Some(&FlagValue::Str("json".to_string()))
451        );
452    }
453
454    #[test]
455    fn test_parse_short_flag() {
456        let parser = make_parser(vec![FlagSchema::boolean("verbose").with_short('v')]);
457        let tokens = tokenize(&args(&["-v"]));
458        let result = parser.parse(&tokens);
459
460        assert!(result.errors.is_empty());
461        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
462    }
463
464    #[test]
465    fn test_parse_short_cluster() {
466        let parser = make_parser(vec![
467            FlagSchema::boolean("verbose").with_short('v'),
468            FlagSchema::boolean("all").with_short('a'),
469            FlagSchema::boolean("force").with_short('f'),
470        ]);
471        let tokens = tokenize(&args(&["-vaf"]));
472        let result = parser.parse(&tokens);
473
474        assert!(result.errors.is_empty());
475        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
476        assert_eq!(result.flags.get("all"), Some(&FlagValue::Bool(true)));
477        assert_eq!(result.flags.get("force"), Some(&FlagValue::Bool(true)));
478    }
479
480    #[test]
481    fn test_parse_short_cluster_last_takes_value() {
482        let parser = make_parser(vec![
483            FlagSchema::boolean("verbose").with_short('v'),
484            FlagSchema::new("output").with_short('o'),
485        ]);
486        let tokens = tokenize(&args(&["-vo", "json"]));
487        let result = parser.parse(&tokens);
488
489        assert!(result.errors.is_empty());
490        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
491        assert_eq!(
492            result.flags.get("output"),
493            Some(&FlagValue::Str("json".to_string()))
494        );
495        assert!(result.positionals.is_empty());
496    }
497
498    #[test]
499    fn test_parse_negation() {
500        let parser = make_parser(vec![FlagSchema::boolean("verbose").with_short('v')]);
501        let tokens = tokenize(&args(&["--no-verbose"]));
502        let result = parser.parse(&tokens);
503
504        assert!(result.errors.is_empty());
505        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(false)));
506    }
507
508    #[test]
509    fn test_parse_unknown_flag_suggests() {
510        let parser = make_parser(vec![
511            FlagSchema::boolean("verbose"),
512            FlagSchema::new("output"),
513        ]);
514        let tokens = tokenize(&args(&["--verbos"]));
515        let result = parser.parse(&tokens);
516
517        assert_eq!(result.errors.len(), 1);
518        if let ParseError::UnknownFlag {
519            ref flag,
520            ref suggestions,
521        } = result.errors[0]
522        {
523            assert_eq!(flag, "--verbos");
524            assert!(suggestions.contains(&"--verbose".to_string()));
525        } else {
526            panic!("expected UnknownFlag error");
527        }
528    }
529
530    #[test]
531    fn test_parse_missing_value() {
532        let parser = make_parser(vec![FlagSchema::new("output")]);
533        let tokens = tokenize(&args(&["--output"]));
534        let result = parser.parse(&tokens);
535
536        assert_eq!(result.errors.len(), 1);
537        assert!(matches!(
538          &result.errors[0],
539          ParseError::MissingFlagValue { flag, .. } if flag == "--output"
540        ));
541    }
542
543    #[test]
544    fn test_parse_invalid_type() {
545        let parser = make_parser(vec![{
546            let mut s = FlagSchema::new("threads");
547            s.value_type = ValueType::Integer;
548            s
549        }]);
550        let tokens = tokenize(&args(&["--threads=abc"]));
551        let result = parser.parse(&tokens);
552
553        assert_eq!(result.errors.len(), 1);
554        assert!(matches!(
555          &result.errors[0],
556          ParseError::InvalidValue { flag, value, .. } if flag == "--threads" && value == "abc"
557        ));
558    }
559
560    #[test]
561    fn test_parse_invalid_choice() {
562        let parser = make_parser(vec![
563            FlagSchema::new("output").with_choices(&["text", "json", "yaml"])
564        ]);
565        let tokens = tokenize(&args(&["--output=xml"]));
566        let result = parser.parse(&tokens);
567
568        assert_eq!(result.errors.len(), 1);
569        assert!(matches!(
570          &result.errors[0],
571          ParseError::InvalidChoice { flag, value, .. } if flag == "--output" && value == "xml"
572        ));
573    }
574
575    #[test]
576    fn test_parse_required_missing() {
577        let parser = make_parser(vec![FlagSchema::new("target").required()]);
578        let tokens = tokenize(&args(&[]));
579        let result = parser.parse(&tokens);
580
581        assert_eq!(result.errors.len(), 1);
582        assert!(matches!(
583          &result.errors[0],
584          ParseError::MissingRequired { flag } if flag == "--target"
585        ));
586    }
587
588    #[test]
589    fn test_parse_defaults_applied() {
590        let parser = make_parser(vec![
591            FlagSchema::new("output").with_default("text"),
592            FlagSchema::boolean("verbose"),
593        ]);
594        let tokens = tokenize(&args(&[]));
595        let result = parser.parse(&tokens);
596
597        assert!(result.errors.is_empty());
598        assert_eq!(
599            result.flags.get("output"),
600            Some(&FlagValue::Str("text".to_string()))
601        );
602        // Boolean without default should not be in the map.
603        assert!(!result.flags.contains_key("verbose"));
604    }
605
606    #[test]
607    fn test_parse_end_of_options() {
608        let parser = make_parser(vec![FlagSchema::boolean("verbose").with_short('v')]);
609        let tokens = tokenize(&args(&["-v", "--", "--not-a-flag", "target"]));
610        let result = parser.parse(&tokens);
611
612        assert!(result.errors.is_empty());
613        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
614        assert_eq!(result.positionals, vec!["--not-a-flag", "target"]);
615    }
616
617    #[test]
618    fn test_parse_positionals_preserved() {
619        let parser = make_parser(vec![FlagSchema::boolean("verbose").with_short('v')]);
620        let tokens = tokenize(&args(&["server", "192.168.1.1", "-v", "extra"]));
621        let result = parser.parse(&tokens);
622
623        assert!(result.errors.is_empty());
624        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
625        assert_eq!(result.positionals, vec!["server", "192.168.1.1", "extra"]);
626    }
627
628    #[test]
629    fn test_parse_mixed_realistic() {
630        // red serve --path /data --bind 0.0.0.0:6380 --role primary -vo json --no-color
631        let parser = make_parser(vec![
632            FlagSchema::new("path").with_short('p'),
633            FlagSchema::new("bind").with_short('b'),
634            FlagSchema::new("role").with_short('r'),
635            FlagSchema::boolean("verbose").with_short('v'),
636            FlagSchema::new("output")
637                .with_short('o')
638                .with_choices(&["text", "json", "yaml"]),
639            FlagSchema::boolean("no-color"),
640        ]);
641        let tokens = tokenize(&args(&[
642            "server",
643            "--path",
644            "/data",
645            "--bind",
646            "0.0.0.0:6380",
647            "--role",
648            "primary",
649            "-vo",
650            "json",
651            "--no-color",
652        ]));
653        let result = parser.parse(&tokens);
654
655        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
656        assert_eq!(result.positionals, vec!["server"]);
657        assert_eq!(
658            result.flags.get("path"),
659            Some(&FlagValue::Str("/data".to_string()))
660        );
661        assert_eq!(
662            result.flags.get("bind"),
663            Some(&FlagValue::Str("0.0.0.0:6380".to_string()))
664        );
665        assert_eq!(
666            result.flags.get("role"),
667            Some(&FlagValue::Str("primary".to_string()))
668        );
669        assert_eq!(result.flags.get("verbose"), Some(&FlagValue::Bool(true)));
670        assert_eq!(
671            result.flags.get("output"),
672            Some(&FlagValue::Str("json".to_string()))
673        );
674        assert_eq!(result.flags.get("no-color"), Some(&FlagValue::Bool(true)));
675    }
676}