1mod 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 UnknownFlag {
18 flag: String,
19 suggestions: Vec<String>,
20 },
21 MissingFlagValue { flag: String, expected_type: String },
23 InvalidValue {
25 flag: String,
26 value: String,
27 expected_type: String,
28 reason: String,
29 },
30 InvalidChoice {
32 flag: String,
33 value: String,
34 allowed: Vec<String>,
35 },
36 MissingRequired { flag: String },
38 UnknownCommand {
40 tokens: Vec<String>,
41 suggestions: Vec<String>,
42 },
43 MissingResource {
45 domain: String,
46 available: Vec<String>,
47 },
48 MissingVerb {
50 domain: String,
51 resource: String,
52 available: Vec<String>,
53 },
54 HelpRequested { text: String },
56 VersionRequested { text: String },
58 Other(String),
60}
61
62impl ParseError {
63 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 pub fn format_json(&self) -> String {
242 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 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
382pub 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 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
428pub 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 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}