1use colored::Colorize;
28
29use crate::error::{
30 ConfigError, DynamicCliError, ExecutionError, ParseError, RegistryError, ValidationError,
31};
32
33fn color_error(s: &str) -> String {
39 s.red().bold().to_string()
40}
41
42fn color_question(s: &str) -> String {
44 s.yellow().bold().to_string()
45}
46
47fn color_bullet(s: &str) -> String {
49 s.cyan().to_string()
50}
51
52fn color_suggestion(s: &str) -> String {
54 s.green().to_string()
55}
56
57fn color_info(s: &str) -> String {
59 s.blue().bold().to_string()
60}
61
62fn color_type_name(s: &str) -> String {
64 s.cyan().to_string()
65}
66
67fn color_arg_name(s: &str) -> String {
69 s.yellow().to_string()
70}
71
72fn color_value(s: &str) -> String {
74 s.red().to_string()
75}
76
77fn color_dimmed(s: &str) -> String {
79 s.dimmed().to_string()
80}
81
82pub fn display_error(error: &DynamicCliError) {
102 eprintln!("{}", format_error(error));
103}
104
105pub fn format_error(error: &DynamicCliError) -> String {
138 let mut output = String::new();
139
140 output.push_str(&format!("{} ", color_error("Error:")));
141
142 match error {
143 DynamicCliError::Parse(e) => format_parse_error(&mut output, e),
144 DynamicCliError::Config(e) => format_config_error(&mut output, e),
145 DynamicCliError::Validation(e) => format_validation_error(&mut output, e),
146 DynamicCliError::Execution(e) => format_execution_error(&mut output, e),
147 DynamicCliError::Registry(e) => format_registry_error(&mut output, e),
148 DynamicCliError::Io(e) => output.push_str(&format!("{}\n", e)),
149 }
150
151 output
152}
153
154fn format_parse_error(output: &mut String, error: &ParseError) {
160 output.push_str(&format!("{}\n", error));
161
162 match error {
163 ParseError::UnknownCommand { suggestions, .. } if !suggestions.is_empty() => {
164 output.push_str(&format!("\n{} Did you mean:\n", color_question("?")));
165 for s in suggestions {
166 output.push_str(&format!(
167 " {} {}\n",
168 color_bullet("•"),
169 color_suggestion(s)
170 ));
171 }
172 }
173
174 ParseError::UnknownOption { suggestions, .. } if !suggestions.is_empty() => {
175 output.push_str(&format!("\n{} Did you mean:\n", color_question("?")));
176 for s in suggestions {
177 output.push_str(&format!(
178 " {} {}\n",
179 color_bullet("•"),
180 color_suggestion(s)
181 ));
182 }
183 }
184
185 ParseError::TypeParseError {
186 arg_name,
187 expected_type,
188 value,
189 ..
190 } => {
191 output.push_str(&format!(
192 "\n{} Expected type {} for argument {}, got: {}\n",
193 color_info("ℹ"),
194 color_type_name(expected_type),
195 color_arg_name(arg_name),
196 color_value(value)
197 ));
198 }
199
200 ParseError::MissingArgument { suggestion, .. }
201 | ParseError::MissingOption { suggestion, .. }
202 | ParseError::TooManyArguments { suggestion, .. } => {
203 append_suggestion(output, suggestion.as_deref());
204 }
205
206 _ => {}
207 }
208}
209
210fn format_config_error(output: &mut String, error: &ConfigError) {
212 match error {
213 ConfigError::YamlParse {
214 source,
215 line,
216 column,
217 } => {
218 output.push_str(&format!("{}\n", source));
219 if let (Some(l), Some(c)) = (line, column) {
220 output.push_str(&format!(
221 " {} line {}, column {}\n",
222 color_dimmed("at"),
223 color_arg_name(&l.to_string()),
224 color_arg_name(&c.to_string())
225 ));
226 }
227 }
228
229 ConfigError::JsonParse {
230 source,
231 line,
232 column,
233 } => {
234 output.push_str(&format!("{}\n", source));
235 output.push_str(&format!(
236 " {} line {}, column {}\n",
237 color_dimmed("at"),
238 color_arg_name(&line.to_string()),
239 color_arg_name(&column.to_string())
240 ));
241 }
242
243 ConfigError::InvalidSchema {
244 reason,
245 path,
246 suggestion,
247 } => {
248 output.push_str(&format!("{}\n", reason));
249 if let Some(p) = path {
250 output.push_str(&format!(
251 " {} {}\n",
252 color_dimmed("in"),
253 color_type_name(p)
254 ));
255 }
256 append_suggestion(output, suggestion.as_deref());
257 }
258
259 ConfigError::FileNotFound { suggestion, .. }
260 | ConfigError::UnsupportedFormat { suggestion, .. }
261 | ConfigError::DuplicateCommand { suggestion, .. }
262 | ConfigError::UnknownType { suggestion, .. }
263 | ConfigError::Inconsistency { suggestion, .. } => {
264 output.push_str(&format!("{}\n", error));
265 append_suggestion(output, suggestion.as_deref());
266 }
267 }
268}
269
270fn format_validation_error(output: &mut String, error: &ValidationError) {
272 output.push_str(&format!("{}\n", error));
273
274 let suggestion = match error {
275 ValidationError::FileNotFound { suggestion, .. } => suggestion.as_deref(),
276 ValidationError::OutOfRange { suggestion, .. } => suggestion.as_deref(),
277 ValidationError::CustomConstraint { suggestion, .. } => suggestion.as_deref(),
278 ValidationError::MissingDependency { suggestion, .. } => suggestion.as_deref(),
279 ValidationError::MutuallyExclusive { suggestion, .. } => suggestion.as_deref(),
280 ValidationError::InvalidExtension { .. } => None,
282 };
283
284 append_suggestion(output, suggestion);
285}
286
287fn format_execution_error(output: &mut String, error: &ExecutionError) {
289 output.push_str(&format!("{}\n", error));
290
291 let suggestion = match error {
292 ExecutionError::HandlerNotFound { suggestion, .. } => suggestion.as_deref(),
293 ExecutionError::ContextDowncastFailed { suggestion, .. } => suggestion.as_deref(),
294 ExecutionError::InvalidContextState { suggestion, .. } => suggestion.as_deref(),
295 ExecutionError::CommandFailed(_) | ExecutionError::Interrupted => None,
297 };
298
299 append_suggestion(output, suggestion);
300}
301
302fn format_registry_error(output: &mut String, error: &RegistryError) {
304 output.push_str(&format!("{}\n", error));
305
306 let suggestion = match error {
307 RegistryError::DuplicateRegistration { suggestion, .. } => suggestion.as_deref(),
308 RegistryError::DuplicateAlias { suggestion, .. } => suggestion.as_deref(),
309 RegistryError::MissingHandler { suggestion, .. } => suggestion.as_deref(),
310 };
311
312 append_suggestion(output, suggestion);
313}
314
315fn append_suggestion(output: &mut String, suggestion: Option<&str>) {
329 if let Some(s) = suggestion {
330 output.push_str(&format!(" {} {}\n", color_info("ℹ"), s));
331 }
332}
333
334#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::path::PathBuf;
342
343 #[test]
346 fn test_format_config_file_not_found_contains_path() {
347 let error: DynamicCliError = ConfigError::FileNotFound {
348 path: PathBuf::from("test.yaml"),
349 suggestion: None,
350 }
351 .into();
352
353 let formatted = format_error(&error);
354 assert!(formatted.contains("Error:"));
355 assert!(formatted.contains("test.yaml"));
356 }
357
358 #[test]
359 fn test_format_config_file_not_found_with_suggestion() {
360 let error: DynamicCliError = ConfigError::FileNotFound {
361 path: PathBuf::from("test.yaml"),
362 suggestion: Some("Verify the path.".to_string()),
363 }
364 .into();
365
366 let formatted = format_error(&error);
367 assert!(formatted.contains("Verify the path."));
368 }
369
370 #[test]
371 fn test_format_config_file_not_found_no_suggestion_no_hint_line() {
372 let error: DynamicCliError = ConfigError::FileNotFound {
373 path: PathBuf::from("test.yaml"),
374 suggestion: None,
375 }
376 .into();
377
378 let formatted = format_error(&error);
379 assert!(!formatted.contains('ℹ'));
381 }
382
383 #[test]
384 fn test_format_config_unsupported_format_with_suggestion() {
385 let error: DynamicCliError = ConfigError::UnsupportedFormat {
386 extension: ".toml".to_string(),
387 suggestion: Some("Use .yaml instead.".to_string()),
388 }
389 .into();
390
391 let formatted = format_error(&error);
392 assert!(formatted.contains(".toml"));
393 assert!(formatted.contains("Use .yaml instead."));
394 }
395
396 #[test]
397 fn test_format_config_yaml_parse_contains_location() {
398 let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: [")
399 .err()
400 .unwrap();
401
402 let error: DynamicCliError = ConfigError::yaml_parse_with_location(yaml_error).into();
403 let formatted = format_error(&error);
404 assert!(formatted.contains("Error:"));
405 }
406
407 #[test]
408 fn test_format_config_invalid_schema_with_path_and_suggestion() {
409 let error: DynamicCliError = ConfigError::InvalidSchema {
410 reason: "missing field".to_string(),
411 path: Some("commands[0]".to_string()),
412 suggestion: Some("Add a name field.".to_string()),
413 }
414 .into();
415
416 let formatted = format_error(&error);
417 assert!(formatted.contains("missing field"));
418 assert!(formatted.contains("commands[0]"));
419 assert!(formatted.contains("Add a name field."));
420 }
421
422 #[test]
425 fn test_format_parse_unknown_command_with_suggestions() {
426 let error: DynamicCliError = ParseError::UnknownCommand {
427 command: "simulat".to_string(),
428 suggestions: vec!["simulate".to_string(), "validation".to_string()],
429 }
430 .into();
431
432 let formatted = format_error(&error);
433 assert!(formatted.contains("Unknown command"));
434 assert!(formatted.contains("simulat"));
435 assert!(formatted.contains("Did you mean"));
436 assert!(formatted.contains("simulate"));
437 }
438
439 #[test]
440 fn test_format_parse_unknown_command_no_suggestions() {
441 let error: DynamicCliError = ParseError::UnknownCommand {
442 command: "xyz".to_string(),
443 suggestions: vec![],
444 }
445 .into();
446
447 let formatted = format_error(&error);
448 assert!(formatted.contains("xyz"));
449 assert!(!formatted.contains("Did you mean"));
450 }
451
452 #[test]
453 fn test_format_parse_missing_argument_with_suggestion() {
454 let error: DynamicCliError = ParseError::MissingArgument {
455 argument: "file".to_string(),
456 command: "process".to_string(),
457 suggestion: Some("Run --help process to see required arguments.".to_string()),
458 }
459 .into();
460
461 let formatted = format_error(&error);
462 assert!(formatted.contains("file"));
463 assert!(formatted.contains("Run --help process"));
464 }
465
466 #[test]
467 fn test_format_parse_missing_option_with_suggestion() {
468 let error: DynamicCliError = ParseError::MissingOption {
469 option: "output".to_string(),
470 command: "export".to_string(),
471 suggestion: Some("Run --help export to see required options.".to_string()),
472 }
473 .into();
474
475 let formatted = format_error(&error);
476 assert!(formatted.contains("output"));
477 assert!(formatted.contains("Run --help export"));
478 }
479
480 #[test]
481 fn test_format_parse_too_many_arguments_with_suggestion() {
482 let error: DynamicCliError = ParseError::TooManyArguments {
483 command: "run".to_string(),
484 expected: 1,
485 got: 3,
486 suggestion: Some("Run --help run for the expected usage.".to_string()),
487 }
488 .into();
489
490 let formatted = format_error(&error);
491 assert!(formatted.contains("run"));
492 assert!(formatted.contains("Run --help run"));
493 }
494
495 #[test]
496 fn test_format_parse_type_parse_error_shows_info_block() {
497 let error: DynamicCliError = ParseError::TypeParseError {
498 arg_name: "count".to_string(),
499 expected_type: "integer".to_string(),
500 value: "abc".to_string(),
501 details: None,
502 }
503 .into();
504
505 let formatted = format_error(&error);
506 assert!(formatted.contains("integer"));
507 assert!(formatted.contains("count"));
508 assert!(formatted.contains("abc"));
509 }
510
511 #[test]
514 fn test_format_validation_file_not_found_with_suggestion() {
515 let error: DynamicCliError = ValidationError::FileNotFound {
516 path: PathBuf::from("data.csv"),
517 arg_name: "input".to_string(),
518 suggestion: Some("Check that the file exists.".to_string()),
519 }
520 .into();
521
522 let formatted = format_error(&error);
523 assert!(formatted.contains("data.csv"));
524 assert!(formatted.contains("Check that the file exists."));
525 }
526
527 #[test]
528 fn test_format_validation_out_of_range_with_suggestion() {
529 let error: DynamicCliError = ValidationError::OutOfRange {
530 arg_name: "percentage".to_string(),
531 value: 150.0,
532 min: 0.0,
533 max: 100.0,
534 suggestion: Some("Value must be between 0 and 100.".to_string()),
535 }
536 .into();
537
538 let formatted = format_error(&error);
539 assert!(formatted.contains("percentage"));
540 assert!(formatted.contains("Value must be between 0 and 100."));
541 }
542
543 #[test]
544 fn test_format_validation_mutually_exclusive_with_suggestion() {
545 let error: DynamicCliError = ValidationError::MutuallyExclusive {
546 arg1: "--verbose".to_string(),
547 arg2: "--quiet".to_string(),
548 suggestion: Some("Remove one of the two conflicting options.".to_string()),
549 }
550 .into();
551
552 let formatted = format_error(&error);
553 assert!(formatted.contains("--verbose"));
554 assert!(formatted.contains("Remove one of the two conflicting options."));
555 }
556
557 #[test]
558 fn test_format_validation_missing_dependency_with_suggestion() {
559 let error: DynamicCliError = ValidationError::MissingDependency {
560 arg_name: "format".to_string(),
561 required_arg: "output".to_string(),
562 suggestion: Some("Add --output to your command.".to_string()),
563 }
564 .into();
565
566 let formatted = format_error(&error);
567 assert!(formatted.contains("format"));
568 assert!(formatted.contains("Add --output to your command."));
569 }
570
571 #[test]
572 fn test_format_validation_invalid_extension_no_suggestion_line() {
573 let error: DynamicCliError = ValidationError::InvalidExtension {
575 arg_name: "input".to_string(),
576 path: PathBuf::from("data.png"),
577 expected: vec![".csv".to_string(), ".tsv".to_string()],
578 }
579 .into();
580
581 let formatted = format_error(&error);
582 assert!(formatted.contains("data.png"));
583 assert!(!formatted.contains('ℹ'));
584 }
585
586 #[test]
589 fn test_format_execution_handler_not_found_with_suggestion() {
590 let error: DynamicCliError = ExecutionError::HandlerNotFound {
591 command: "run".to_string(),
592 implementation: "run_handler".to_string(),
593 suggestion: Some(
594 "Ensure .register_handler(\"run_handler\", ...) was called.".to_string(),
595 ),
596 }
597 .into();
598
599 let formatted = format_error(&error);
600 assert!(formatted.contains("run"));
601 assert!(formatted.contains("run_handler"));
602 assert!(formatted.contains("register_handler"));
603 }
604
605 #[test]
606 fn test_format_execution_context_downcast_failed_with_suggestion() {
607 let error: DynamicCliError = ExecutionError::ContextDowncastFailed {
608 expected_type: "MyCtx".to_string(),
609 suggestion: Some("Check the context type.".to_string()),
610 }
611 .into();
612
613 let formatted = format_error(&error);
614 assert!(formatted.contains("MyCtx"));
615 assert!(formatted.contains("Check the context type."));
616 }
617
618 #[test]
619 fn test_format_execution_interrupted_no_suggestion() {
620 let error: DynamicCliError = ExecutionError::Interrupted.into();
621 let formatted = format_error(&error);
622 assert!(formatted.contains("interrupted"));
623 assert!(!formatted.contains('ℹ'));
624 }
625
626 #[test]
629 fn test_format_registry_missing_handler_with_suggestion() {
630 let error: DynamicCliError = RegistryError::MissingHandler {
631 command: "export".to_string(),
632 suggestion: Some("Call .register_handler(\"export\", ...) before running.".to_string()),
633 }
634 .into();
635
636 let formatted = format_error(&error);
637 assert!(formatted.contains("export"));
638 assert!(formatted.contains("register_handler"));
639 }
640
641 #[test]
642 fn test_format_registry_duplicate_registration_with_suggestion() {
643 let error: DynamicCliError = RegistryError::DuplicateRegistration {
644 name: "run".to_string(),
645 suggestion: Some("Command names must be unique.".to_string()),
646 }
647 .into();
648
649 let formatted = format_error(&error);
650 assert!(formatted.contains("run"));
651 assert!(formatted.contains("Command names must be unique."));
652 }
653
654 #[test]
655 fn test_format_registry_duplicate_alias_with_suggestion() {
656 let error: DynamicCliError = RegistryError::DuplicateAlias {
657 alias: "r".to_string(),
658 existing_command: "run".to_string(),
659 suggestion: Some("Choose a different alias.".to_string()),
660 }
661 .into();
662
663 let formatted = format_error(&error);
664 assert!(formatted.contains("run"));
665 assert!(formatted.contains("Choose a different alias."));
666 }
667
668 #[test]
671 fn test_display_error_does_not_panic() {
672 let error: DynamicCliError = ConfigError::FileNotFound {
673 path: PathBuf::from("test.yaml"),
674 suggestion: None,
675 }
676 .into();
677 display_error(&error);
679 }
680}