Skip to main content

reddb_server/cli/
error.rs

1/// CLI parse error with context for helpful messages.
2///
3/// Self-contained ANSI color constants -- no external terminal dependency.
4///
5/// Minimal ANSI escape codes for error formatting.
6mod ansi {
7    pub const BOLD: &str = "\x1b[1m";
8    pub const RESET: &str = "\x1b[0m";
9    pub const CYAN: &str = "\x1b[36m";
10    pub const YELLOW: &str = "\x1b[33m";
11    pub const GREEN: &str = "\x1b[32m";
12}
13
14#[derive(Debug, Clone)]
15pub enum ParseError {
16    /// Unknown flag not in schema
17    UnknownFlag {
18        flag: String,
19        suggestions: Vec<String>,
20    },
21    /// Flag expects a value but none provided
22    MissingFlagValue { flag: String, expected_type: String },
23    /// Flag value doesn't match expected type
24    InvalidValue {
25        flag: String,
26        value: String,
27        expected_type: String,
28        reason: String,
29    },
30    /// Flag value not in allowed choices
31    InvalidChoice {
32        flag: String,
33        value: String,
34        allowed: Vec<String>,
35    },
36    /// Required flag not provided
37    MissingRequired { flag: String },
38    /// Unknown command (domain/resource/verb not found)
39    UnknownCommand {
40        tokens: Vec<String>,
41        suggestions: Vec<String>,
42    },
43    /// Domain found but missing resource
44    MissingResource {
45        domain: String,
46        available: Vec<String>,
47    },
48    /// Domain+resource found but missing verb
49    MissingVerb {
50        domain: String,
51        resource: String,
52        available: Vec<String>,
53    },
54    /// Help was requested (not really an error)
55    HelpRequested { text: String },
56    /// Version was requested (not really an error)
57    VersionRequested { text: String },
58    /// Generic error with message
59    Other(String),
60}
61
62impl ParseError {
63    /// Format as human-readable error message with colors
64    pub fn format_human(&self) -> String {
65        match self {
66            ParseError::UnknownFlag { flag, suggestions } => {
67                let mut out = format!(
68                    "{}error:{} unknown flag '{}{}{}'\n",
69                    ansi::BOLD,
70                    ansi::RESET,
71                    ansi::CYAN,
72                    flag,
73                    ansi::RESET,
74                );
75                if !suggestions.is_empty() {
76                    out.push_str(&format!(
77                        "\n  {}Did you mean:{}\n",
78                        ansi::YELLOW,
79                        ansi::RESET
80                    ));
81                    for s in suggestions {
82                        out.push_str(&format!("    {}{}{}\n", ansi::GREEN, s, ansi::RESET));
83                    }
84                }
85                out
86            }
87            ParseError::MissingFlagValue {
88                flag,
89                expected_type,
90            } => {
91                format!(
92                    "{}error:{} flag '{}{}{}' requires a value of type {}{}{}\n",
93                    ansi::BOLD,
94                    ansi::RESET,
95                    ansi::CYAN,
96                    flag,
97                    ansi::RESET,
98                    ansi::YELLOW,
99                    expected_type,
100                    ansi::RESET,
101                )
102            }
103            ParseError::InvalidValue {
104                flag,
105                value,
106                expected_type,
107                reason,
108            } => {
109                format!(
110                    "{}error:{} invalid value '{}{}{}' for {}{}{}\n\n  Expected {}{}{}: {}\n",
111                    ansi::BOLD,
112                    ansi::RESET,
113                    ansi::CYAN,
114                    value,
115                    ansi::RESET,
116                    ansi::CYAN,
117                    flag,
118                    ansi::RESET,
119                    ansi::YELLOW,
120                    expected_type,
121                    ansi::RESET,
122                    reason,
123                )
124            }
125            ParseError::InvalidChoice {
126                flag,
127                value,
128                allowed,
129            } => {
130                let mut out = format!(
131                    "{}error:{} invalid value '{}{}{}' for {}{}{}\n",
132                    ansi::BOLD,
133                    ansi::RESET,
134                    ansi::CYAN,
135                    value,
136                    ansi::RESET,
137                    ansi::CYAN,
138                    flag,
139                    ansi::RESET,
140                );
141                out.push_str(&format!(
142                    "\n  {}Allowed values:{} {}\n",
143                    ansi::YELLOW,
144                    ansi::RESET,
145                    allowed.join(", "),
146                ));
147                out
148            }
149            ParseError::MissingRequired { flag } => {
150                format!(
151                    "{}error:{} missing required flag '{}{}{}'\n",
152                    ansi::BOLD,
153                    ansi::RESET,
154                    ansi::CYAN,
155                    flag,
156                    ansi::RESET,
157                )
158            }
159            ParseError::UnknownCommand {
160                tokens,
161                suggestions,
162            } => {
163                let cmd = tokens.join(" ");
164                let mut out = format!(
165                    "{}error:{} unknown command '{}{}{}'\n",
166                    ansi::BOLD,
167                    ansi::RESET,
168                    ansi::CYAN,
169                    cmd,
170                    ansi::RESET,
171                );
172                if !suggestions.is_empty() {
173                    out.push_str(&format!(
174                        "\n  {}Did you mean:{}\n",
175                        ansi::YELLOW,
176                        ansi::RESET
177                    ));
178                    for s in suggestions {
179                        out.push_str(&format!("    {}red {}{}\n", ansi::GREEN, s, ansi::RESET));
180                    }
181                }
182                out
183            }
184            ParseError::MissingResource { domain, available } => {
185                let mut out = format!(
186                    "{}error:{} missing resource for '{}{}{}'\n",
187                    ansi::BOLD,
188                    ansi::RESET,
189                    ansi::CYAN,
190                    domain,
191                    ansi::RESET,
192                );
193                if !available.is_empty() {
194                    out.push_str(&format!(
195                        "\n  {}Available resources:{}\n",
196                        ansi::YELLOW,
197                        ansi::RESET,
198                    ));
199                    for r in available {
200                        out.push_str(&format!("    {}{}{}\n", ansi::GREEN, r, ansi::RESET));
201                    }
202                }
203                out
204            }
205            ParseError::MissingVerb {
206                domain,
207                resource,
208                available,
209            } => {
210                let mut out = format!(
211                    "{}error:{} missing verb for '{}{} {}{}'\n",
212                    ansi::BOLD,
213                    ansi::RESET,
214                    ansi::CYAN,
215                    domain,
216                    resource,
217                    ansi::RESET,
218                );
219                if !available.is_empty() {
220                    out.push_str(&format!(
221                        "\n  {}Available verbs:{}\n",
222                        ansi::YELLOW,
223                        ansi::RESET,
224                    ));
225                    for v in available {
226                        out.push_str(&format!("    {}{}{}\n", ansi::GREEN, v, ansi::RESET));
227                    }
228                }
229                out
230            }
231            ParseError::HelpRequested { text } | ParseError::VersionRequested { text } => {
232                text.clone()
233            }
234            ParseError::Other(msg) => {
235                format!("{}error:{} {}\n", ansi::BOLD, ansi::RESET, msg)
236            }
237        }
238    }
239
240    /// Format as JSON error object
241    pub fn format_json(&self) -> String {
242        // Manual JSON construction -- no serde, no external crates
243        let escape = |s: &str| -> String {
244            s.replace('\\', "\\\\")
245                .replace('"', "\\\"")
246                .replace('\n', "\\n")
247                .replace('\t', "\\t")
248        };
249
250        match self {
251            ParseError::UnknownFlag { flag, suggestions } => {
252                let suggestions_json: Vec<String> = suggestions
253                    .iter()
254                    .map(|s| format!("\"{}\"", escape(s)))
255                    .collect();
256                format!(
257                    "{{\"error\":\"unknown_flag\",\"flag\":\"{}\",\"suggestions\":[{}]}}",
258                    escape(flag),
259                    suggestions_json.join(","),
260                )
261            }
262            ParseError::MissingFlagValue {
263                flag,
264                expected_type,
265            } => {
266                format!(
267                    "{{\"error\":\"missing_flag_value\",\"flag\":\"{}\",\"expected_type\":\"{}\"}}",
268                    escape(flag),
269                    escape(expected_type),
270                )
271            }
272            ParseError::InvalidValue {
273                flag,
274                value,
275                expected_type,
276                reason,
277            } => {
278                format!(
279                    "{{\"error\":\"invalid_value\",\"flag\":\"{}\",\"value\":\"{}\",\"expected_type\":\"{}\",\"reason\":\"{}\"}}",
280                    escape(flag),
281                    escape(value),
282                    escape(expected_type),
283                    escape(reason),
284                )
285            }
286            ParseError::InvalidChoice {
287                flag,
288                value,
289                allowed,
290            } => {
291                let allowed_json: Vec<String> = allowed
292                    .iter()
293                    .map(|s| format!("\"{}\"", escape(s)))
294                    .collect();
295                format!(
296          "{{\"error\":\"invalid_choice\",\"flag\":\"{}\",\"value\":\"{}\",\"allowed\":[{}]}}",
297          escape(flag),
298          escape(value),
299          allowed_json.join(","),
300        )
301            }
302            ParseError::MissingRequired { flag } => {
303                format!(
304                    "{{\"error\":\"missing_required\",\"flag\":\"{}\"}}",
305                    escape(flag),
306                )
307            }
308            ParseError::UnknownCommand {
309                tokens,
310                suggestions,
311            } => {
312                let tokens_json: Vec<String> = tokens
313                    .iter()
314                    .map(|s| format!("\"{}\"", escape(s)))
315                    .collect();
316                let suggestions_json: Vec<String> = suggestions
317                    .iter()
318                    .map(|s| format!("\"{}\"", escape(s)))
319                    .collect();
320                format!(
321                    "{{\"error\":\"unknown_command\",\"tokens\":[{}],\"suggestions\":[{}]}}",
322                    tokens_json.join(","),
323                    suggestions_json.join(","),
324                )
325            }
326            ParseError::MissingResource { domain, available } => {
327                let available_json: Vec<String> = available
328                    .iter()
329                    .map(|s| format!("\"{}\"", escape(s)))
330                    .collect();
331                format!(
332                    "{{\"error\":\"missing_resource\",\"domain\":\"{}\",\"available\":[{}]}}",
333                    escape(domain),
334                    available_json.join(","),
335                )
336            }
337            ParseError::MissingVerb {
338                domain,
339                resource,
340                available,
341            } => {
342                let available_json: Vec<String> = available
343                    .iter()
344                    .map(|s| format!("\"{}\"", escape(s)))
345                    .collect();
346                format!(
347          "{{\"error\":\"missing_verb\",\"domain\":\"{}\",\"resource\":\"{}\",\"available\":[{}]}}",
348          escape(domain),
349          escape(resource),
350          available_json.join(","),
351        )
352            }
353            ParseError::HelpRequested { text } => {
354                format!("{{\"type\":\"help\",\"text\":\"{}\"}}", escape(text))
355            }
356            ParseError::VersionRequested { text } => {
357                format!("{{\"type\":\"version\",\"text\":\"{}\"}}", escape(text))
358            }
359            ParseError::Other(msg) => {
360                format!("{{\"error\":\"other\",\"message\":\"{}\"}}", escape(msg))
361            }
362        }
363    }
364
365    /// Is this a "not really an error" variant (help/version)?
366    pub fn is_info(&self) -> bool {
367        matches!(
368            self,
369            ParseError::HelpRequested { .. } | ParseError::VersionRequested { .. }
370        )
371    }
372}
373
374impl std::fmt::Display for ParseError {
375    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376        write!(f, "{}", self.format_human())
377    }
378}
379
380impl std::error::Error for ParseError {}
381
382/// Levenshtein distance for suggestion generation.
383/// Standard dynamic programming O(n*m) with a flat Vec.
384pub fn levenshtein(a: &str, b: &str) -> usize {
385    let a_len = a.len();
386    let b_len = b.len();
387
388    if a_len == 0 {
389        return b_len;
390    }
391    if b_len == 0 {
392        return a_len;
393    }
394
395    let width = b_len + 1;
396    let mut matrix = vec![0usize; (a_len + 1) * width];
397
398    // Initialize first row and column
399    for i in 0..=a_len {
400        matrix[i * width] = i;
401    }
402    for (j, item) in matrix.iter_mut().enumerate().take(b_len + 1) {
403        *item = j;
404    }
405
406    let a_bytes = a.as_bytes();
407    let b_bytes = b.as_bytes();
408
409    for i in 1..=a_len {
410        for j in 1..=b_len {
411            let cost = if a_bytes[i - 1] == b_bytes[j - 1] {
412                0
413            } else {
414                1
415            };
416
417            let delete = matrix[(i - 1) * width + j] + 1;
418            let insert = matrix[i * width + (j - 1)] + 1;
419            let substitute = matrix[(i - 1) * width + (j - 1)] + cost;
420
421            matrix[i * width + j] = delete.min(insert).min(substitute);
422        }
423    }
424
425    matrix[a_len * width + b_len]
426}
427
428/// Generate suggestions for a mistyped string from candidates.
429/// Returns up to `max_results` candidates sorted by ascending distance.
430pub fn suggest(input: &str, candidates: &[&str], max_results: usize) -> Vec<String> {
431    let threshold = 3.max(input.len() / 2);
432    let mut scored: Vec<(usize, &str)> = candidates
433        .iter()
434        .map(|&c| (levenshtein(input, c), c))
435        .filter(|(d, _)| *d <= threshold)
436        .collect();
437
438    scored.sort_by_key(|(d, _)| *d);
439    scored
440        .into_iter()
441        .take(max_results)
442        .map(|(_, c)| c.to_string())
443        .collect()
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn test_levenshtein_identical() {
452        assert_eq!(levenshtein("hello", "hello"), 0);
453    }
454
455    #[test]
456    fn test_levenshtein_one_char() {
457        assert_eq!(levenshtein("cat", "hat"), 1);
458    }
459
460    #[test]
461    fn test_levenshtein_transposition() {
462        // Standard Levenshtein treats transposition as 2 ops (delete+insert)
463        assert_eq!(levenshtein("ab", "ba"), 2);
464    }
465
466    #[test]
467    fn test_levenshtein_empty() {
468        assert_eq!(levenshtein("", "abc"), 3);
469        assert_eq!(levenshtein("xyz", ""), 3);
470        assert_eq!(levenshtein("", ""), 0);
471    }
472
473    #[test]
474    fn test_suggest_finds_close_match() {
475        let candidates = &["json", "yaml", "text"];
476        let results = suggest("jsno", candidates, 3);
477        assert!(!results.is_empty());
478        assert_eq!(results[0], "json");
479    }
480
481    #[test]
482    fn test_suggest_no_match_too_far() {
483        let candidates = &["json", "yaml", "text"];
484        let results = suggest("zzzzzzzzz", candidates, 3);
485        assert!(results.is_empty());
486    }
487
488    #[test]
489    fn test_suggest_respects_max_results() {
490        let candidates = &["scan", "span", "stan", "plan", "swan"];
491        let results = suggest("sca", candidates, 2);
492        assert!(results.len() <= 2);
493    }
494
495    #[test]
496    fn test_format_unknown_flag() {
497        let err = ParseError::UnknownFlag {
498            flag: "--jsno".to_string(),
499            suggestions: vec!["--json".to_string()],
500        };
501        let msg = err.format_human();
502        assert!(msg.contains("unknown flag"));
503        assert!(msg.contains("--jsno"));
504        assert!(msg.contains("--json"));
505    }
506
507    #[test]
508    fn test_format_unknown_command() {
509        let err = ParseError::UnknownCommand {
510            tokens: vec!["serv".to_string(), "start".to_string()],
511            suggestions: vec!["server".to_string()],
512        };
513        let msg = err.format_human();
514        assert!(msg.contains("unknown command"));
515        assert!(msg.contains("serv start"));
516        assert!(msg.contains("server"));
517    }
518
519    #[test]
520    fn test_format_invalid_choice() {
521        let err = ParseError::InvalidChoice {
522            flag: "--output".to_string(),
523            value: "xml".to_string(),
524            allowed: vec!["text".to_string(), "json".to_string(), "yaml".to_string()],
525        };
526        let msg = err.format_human();
527        assert!(msg.contains("invalid value"));
528        assert!(msg.contains("xml"));
529        assert!(msg.contains("--output"));
530        assert!(msg.contains("text"));
531        assert!(msg.contains("json"));
532        assert!(msg.contains("yaml"));
533    }
534
535    #[test]
536    fn test_is_info_for_help() {
537        let err = ParseError::HelpRequested {
538            text: "usage: red ...".to_string(),
539        };
540        assert!(err.is_info());
541    }
542
543    #[test]
544    fn test_is_info_for_version() {
545        let err = ParseError::VersionRequested {
546            text: "red 0.1.0".to_string(),
547        };
548        assert!(err.is_info());
549    }
550
551    #[test]
552    fn test_is_info_false_for_real_errors() {
553        let err = ParseError::Other("something went wrong".to_string());
554        assert!(!err.is_info());
555
556        let err = ParseError::MissingRequired {
557            flag: "--target".to_string(),
558        };
559        assert!(!err.is_info());
560    }
561
562    #[test]
563    fn test_display_delegates_to_format_human() {
564        let err = ParseError::Other("boom".to_string());
565        let display = format!("{}", err);
566        assert_eq!(display, err.format_human());
567    }
568
569    #[test]
570    fn test_format_json_unknown_flag() {
571        let err = ParseError::UnknownFlag {
572            flag: "--jsno".to_string(),
573            suggestions: vec!["--json".to_string()],
574        };
575        let json = err.format_json();
576        assert!(json.contains("\"error\":\"unknown_flag\""));
577        assert!(json.contains("\"flag\":\"--jsno\""));
578        assert!(json.contains("\"--json\""));
579    }
580
581    #[test]
582    fn test_format_json_escapes_special_chars() {
583        let err = ParseError::Other("line1\nline2\t\"quoted\"".to_string());
584        let json = err.format_json();
585        assert!(json.contains("\\n"));
586        assert!(json.contains("\\t"));
587        assert!(json.contains("\\\"quoted\\\""));
588    }
589}