1use console::Style;
7use serde::Serialize;
8
9use super::OutputConfig;
10use crate::exit_code::ExitCode;
11
12const USAGE_SUGGESTION: &str =
13 "Run the command with --help to review the expected arguments and flags.";
14const NETWORK_SUGGESTION: &str =
15 "Retry the command. If the problem persists, verify the endpoint and network connectivity.";
16const AUTH_SUGGESTION: &str =
17 "Verify the alias credentials and permissions, then retry the command.";
18const NOT_FOUND_SUGGESTION: &str = "Check the alias, bucket, or object path and retry the command.";
19const CONFLICT_SUGGESTION: &str =
20 "Review the target resource state and retry with the appropriate overwrite or ignore flag.";
21const UNSUPPORTED_SUGGESTION: &str =
22 "Retry with --force only if you want to bypass capability detection.";
23
24#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
25struct JsonErrorOutput {
26 error: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 code: Option<i32>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 details: Option<JsonErrorDetails>,
31}
32
33#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34struct JsonErrorDetails {
35 #[serde(rename = "type")]
36 error_type: &'static str,
37 message: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 suggestion: Option<String>,
40 retryable: bool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44struct ErrorDescriptor {
45 code: Option<ExitCode>,
46 error_type: &'static str,
47 message: String,
48 suggestion: Option<String>,
49 retryable: bool,
50}
51
52impl ErrorDescriptor {
53 fn from_message(message: &str) -> Self {
54 let message = message.to_string();
55 let (error_type, retryable, suggestion) = infer_error_metadata(&message);
56
57 Self {
58 code: None,
59 error_type,
60 message,
61 suggestion,
62 retryable,
63 }
64 }
65
66 fn from_code(code: ExitCode, message: &str) -> Self {
67 let (error_type, retryable, suggestion) = defaults_for_exit_code(code);
68
69 Self {
70 code: Some(code),
71 error_type,
72 message: message.to_string(),
73 suggestion: suggestion.map(str::to_string),
74 retryable,
75 }
76 }
77
78 fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
79 self.suggestion = Some(suggestion.into());
80 self
81 }
82
83 fn to_json_output(&self) -> JsonErrorOutput {
84 JsonErrorOutput {
85 error: self.message.clone(),
86 code: self.code.map(ExitCode::as_i32),
87 details: Some(JsonErrorDetails {
88 error_type: self.error_type,
89 message: self.message.clone(),
90 suggestion: self.suggestion.clone(),
91 retryable: self.retryable,
92 }),
93 }
94 }
95}
96
97fn defaults_for_exit_code(code: ExitCode) -> (&'static str, bool, Option<&'static str>) {
98 match code {
99 ExitCode::Success => ("success", false, None),
100 ExitCode::GeneralError => ("general_error", false, None),
101 ExitCode::UsageError => ("usage_error", false, Some(USAGE_SUGGESTION)),
102 ExitCode::NetworkError => ("network_error", true, Some(NETWORK_SUGGESTION)),
103 ExitCode::AuthError => ("auth_error", false, Some(AUTH_SUGGESTION)),
104 ExitCode::NotFound => ("not_found", false, Some(NOT_FOUND_SUGGESTION)),
105 ExitCode::Conflict => ("conflict", false, Some(CONFLICT_SUGGESTION)),
106 ExitCode::UnsupportedFeature => {
107 ("unsupported_feature", false, Some(UNSUPPORTED_SUGGESTION))
108 }
109 ExitCode::Interrupted => (
110 "interrupted",
111 true,
112 Some("Retry the command if you still need the operation to complete."),
113 ),
114 }
115}
116
117fn infer_error_metadata(message: &str) -> (&'static str, bool, Option<String>) {
118 let normalized = message.to_ascii_lowercase();
119
120 if normalized.contains("not found") || normalized.contains("does not exist") {
121 return ("not_found", false, Some(NOT_FOUND_SUGGESTION.to_string()));
122 }
123
124 if normalized.contains("access denied")
125 || normalized.contains("unauthorized")
126 || normalized.contains("forbidden")
127 || normalized.contains("authentication")
128 || normalized.contains("credentials")
129 {
130 return ("auth_error", false, Some(AUTH_SUGGESTION.to_string()));
131 }
132
133 if normalized.contains("invalid")
134 || normalized.contains("cannot be empty")
135 || normalized.contains("must be")
136 || normalized.contains("must use")
137 || normalized.contains("must include")
138 || normalized.contains("must specify")
139 || normalized.contains("expected:")
140 || normalized.contains("use -r/--recursive")
141 {
142 return ("usage_error", false, Some(USAGE_SUGGESTION.to_string()));
143 }
144
145 if normalized.contains("already exists")
146 || normalized.contains("conflict")
147 || normalized.contains("precondition")
148 || normalized.contains("destination exists")
149 {
150 return ("conflict", false, Some(CONFLICT_SUGGESTION.to_string()));
151 }
152
153 if normalized.contains("does not support")
154 || normalized.contains("unsupported")
155 || normalized.contains("not yet supported")
156 {
157 return (
158 "unsupported_feature",
159 false,
160 Some(UNSUPPORTED_SUGGESTION.to_string()),
161 );
162 }
163
164 if normalized.contains("timeout")
165 || normalized.contains("network")
166 || normalized.contains("connection")
167 || normalized.contains("temporarily unavailable")
168 || normalized.contains("failed to create s3 client")
169 {
170 return ("network_error", true, Some(NETWORK_SUGGESTION.to_string()));
171 }
172
173 ("general_error", false, None)
174}
175
176#[derive(Debug, Clone)]
178pub struct Theme {
179 pub dir: Style,
181 pub file: Style,
183 pub size: Style,
185 pub date: Style,
187 pub key: Style,
189 pub url: Style,
191 pub name: Style,
193 pub success: Style,
195 pub error: Style,
197 pub warning: Style,
199 pub tree_branch: Style,
201}
202
203impl Default for Theme {
204 fn default() -> Self {
205 Self {
206 dir: Style::new().blue().bold(),
207 file: Style::new(),
208 size: Style::new().green(),
209 date: Style::new().dim(),
210 key: Style::new().cyan(),
211 url: Style::new().cyan().underlined(),
212 name: Style::new().bold(),
213 success: Style::new().green(),
214 error: Style::new().red(),
215 warning: Style::new().yellow(),
216 tree_branch: Style::new().dim(),
217 }
218 }
219}
220
221impl Theme {
222 pub fn plain() -> Self {
224 Self {
225 dir: Style::new(),
226 file: Style::new(),
227 size: Style::new(),
228 date: Style::new(),
229 key: Style::new(),
230 url: Style::new(),
231 name: Style::new(),
232 success: Style::new(),
233 error: Style::new(),
234 warning: Style::new(),
235 tree_branch: Style::new(),
236 }
237 }
238}
239
240#[derive(Debug, Clone)]
245#[allow(dead_code)]
246pub struct Formatter {
247 config: OutputConfig,
248 theme: Theme,
249}
250
251#[allow(dead_code)]
252impl Formatter {
253 pub fn new(config: OutputConfig) -> Self {
255 let theme = if config.no_color || config.json {
256 Theme::plain()
257 } else {
258 Theme::default()
259 };
260 Self { config, theme }
261 }
262
263 pub fn is_json(&self) -> bool {
265 self.config.json
266 }
267
268 pub fn is_quiet(&self) -> bool {
270 self.config.quiet
271 }
272
273 pub fn colors_enabled(&self) -> bool {
275 !self.config.no_color && !self.config.json
276 }
277
278 pub fn theme(&self) -> &Theme {
280 &self.theme
281 }
282
283 pub fn output_config(&self) -> OutputConfig {
285 self.config.clone()
286 }
287
288 pub fn style_dir(&self, text: &str) -> String {
292 self.theme
293 .dir
294 .apply_to(self.sanitize_text(text))
295 .to_string()
296 }
297
298 pub fn style_file(&self, text: &str) -> String {
300 self.theme
301 .file
302 .apply_to(self.sanitize_text(text))
303 .to_string()
304 }
305
306 pub fn style_size(&self, text: &str) -> String {
308 self.theme.size.apply_to(text).to_string()
309 }
310
311 pub fn style_date(&self, text: &str) -> String {
313 self.theme.date.apply_to(text).to_string()
314 }
315
316 pub fn style_key(&self, text: &str) -> String {
318 self.theme
319 .key
320 .apply_to(self.sanitize_text(text))
321 .to_string()
322 }
323
324 pub fn style_url(&self, text: &str) -> String {
326 self.theme
327 .url
328 .apply_to(self.sanitize_text(text))
329 .to_string()
330 }
331
332 pub fn style_name(&self, text: &str) -> String {
334 self.theme
335 .name
336 .apply_to(self.sanitize_text(text))
337 .to_string()
338 }
339
340 pub fn sanitize_text(&self, text: &str) -> String {
341 let mut sanitized = String::with_capacity(text.len());
342 for character in text.chars() {
343 match character {
344 '\n' => sanitized.push_str("\\n"),
345 '\r' => sanitized.push_str("\\r"),
346 '\t' => sanitized.push_str("\\t"),
347 character
348 if character.is_control()
349 || matches!(
350 character,
351 '\u{200e}'
352 | '\u{200f}'
353 | '\u{202a}'..='\u{202e}'
354 | '\u{2066}'..='\u{2069}'
355 ) =>
356 {
357 sanitized.push_str(&format!("\\u{{{:x}}}", character as u32));
358 }
359 character => sanitized.push(character),
360 }
361 }
362 sanitized
363 }
364
365 pub fn style_tree_branch(&self, text: &str) -> String {
367 self.theme.tree_branch.apply_to(text).to_string()
368 }
369
370 pub fn output<T: Serialize + std::fmt::Display>(&self, value: &T) {
377 if self.config.quiet {
378 return;
379 }
380
381 if self.config.json {
382 match serde_json::to_string_pretty(value) {
384 Ok(json) => println!("{json}"),
385 Err(e) => eprintln!("Error serializing output: {e}"),
386 }
387 } else {
388 println!("{value}");
389 }
390 }
391
392 pub fn success(&self, message: &str) {
394 if self.config.quiet {
395 return;
396 }
397
398 if self.config.json {
399 return;
401 }
402
403 let checkmark = self.theme.success.apply_to("✓");
404 println!("{checkmark} {message}");
405 }
406
407 pub fn error(&self, message: &str) {
411 self.emit_error(ErrorDescriptor::from_message(message));
412 }
413
414 pub fn error_with_code(&self, code: ExitCode, message: &str) {
416 self.emit_error(ErrorDescriptor::from_code(code, message));
417 }
418
419 pub fn error_with_suggestion(&self, code: ExitCode, message: &str, suggestion: &str) {
421 self.emit_error(ErrorDescriptor::from_code(code, message).with_suggestion(suggestion));
422 }
423
424 pub fn fail(&self, code: ExitCode, message: &str) -> ExitCode {
426 self.error_with_code(code, message);
427 code
428 }
429
430 pub fn fail_with_suggestion(
432 &self,
433 code: ExitCode,
434 message: &str,
435 suggestion: &str,
436 ) -> ExitCode {
437 self.error_with_suggestion(code, message, suggestion);
438 code
439 }
440
441 fn emit_error(&self, descriptor: ErrorDescriptor) {
442 if self.config.json {
443 let error = descriptor.to_json_output();
444 eprintln!(
445 "{}",
446 serde_json::to_string_pretty(&error).unwrap_or_else(|_| descriptor.message.clone())
447 );
448 } else {
449 let cross = self.theme.error.apply_to("✗");
450 eprintln!("{cross} {}", self.sanitize_text(&descriptor.message));
451 }
452 }
453
454 pub fn warning(&self, message: &str) {
456 if self.config.quiet || self.config.json {
457 return;
458 }
459
460 let warn_icon = self.theme.warning.apply_to("⚠");
461 eprintln!("{warn_icon} {}", self.sanitize_text(message));
462 }
463
464 pub fn json<T: Serialize>(&self, value: &T) {
468 match serde_json::to_string_pretty(value) {
469 Ok(json) => println!("{json}"),
470 Err(e) => eprintln!("Error serializing output: {e}"),
471 }
472 }
473
474 pub fn println(&self, message: &str) {
476 if self.config.quiet {
477 return;
478 }
479 println!("{message}");
480 }
481}
482
483impl Default for Formatter {
484 fn default() -> Self {
485 Self::new(OutputConfig::default())
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn test_formatter_default() {
495 let formatter = Formatter::default();
496 assert!(!formatter.is_json());
497 assert!(!formatter.is_quiet());
498 assert!(formatter.colors_enabled());
499 }
500
501 #[test]
502 fn test_formatter_json_mode() {
503 let config = OutputConfig {
504 json: true,
505 ..Default::default()
506 };
507 let formatter = Formatter::new(config);
508 assert!(formatter.is_json());
509 assert!(!formatter.colors_enabled()); }
511
512 #[test]
513 fn test_formatter_no_color() {
514 let config = OutputConfig {
515 no_color: true,
516 ..Default::default()
517 };
518 let formatter = Formatter::new(config);
519 assert!(!formatter.colors_enabled());
520 }
521
522 #[test]
523 fn test_sanitize_text_escapes_terminal_control_characters() {
524 let formatter = Formatter::default();
525
526 assert_eq!(
527 formatter.sanitize_text("safe\n\u{1b}[31m\u{202e}"),
528 "safe\\n\\u{1b}[31m\\u{202e}"
529 );
530 }
531
532 #[test]
533 fn test_error_descriptor_from_code_sets_defaults() {
534 let descriptor =
535 ErrorDescriptor::from_code(ExitCode::NetworkError, "Failed to create S3 client");
536
537 assert_eq!(descriptor.code, Some(ExitCode::NetworkError));
538 assert_eq!(descriptor.error_type, "network_error");
539 assert!(descriptor.retryable);
540 assert_eq!(descriptor.suggestion.as_deref(), Some(NETWORK_SUGGESTION));
541 }
542
543 #[test]
544 fn test_error_descriptor_from_message_infers_not_found() {
545 let descriptor = ErrorDescriptor::from_message("Alias 'local' not found");
546 let json = descriptor.to_json_output();
547
548 assert_eq!(json.error, "Alias 'local' not found");
549 assert_eq!(json.code, None);
550 let details = json.details.expect("details should be present");
551 assert_eq!(details.error_type, "not_found");
552 assert!(!details.retryable);
553 assert_eq!(details.suggestion.as_deref(), Some(NOT_FOUND_SUGGESTION));
554 }
555
556 #[test]
557 fn test_error_descriptor_from_message_prefers_usage_for_invalid_permission() {
558 let descriptor = ErrorDescriptor::from_message("Invalid permission 'download'");
559 let json = descriptor.to_json_output();
560
561 let details = json.details.expect("details should be present");
562 assert_eq!(details.error_type, "usage_error");
563 assert!(!details.retryable);
564 assert_eq!(details.suggestion.as_deref(), Some(USAGE_SUGGESTION));
565 }
566
567 #[test]
568 fn test_error_descriptor_from_message_prefers_auth_for_invalid_credentials() {
569 let descriptor = ErrorDescriptor::from_message("Invalid credentials for alias 'local'");
570 let json = descriptor.to_json_output();
571
572 let details = json.details.expect("details should be present");
573 assert_eq!(details.error_type, "auth_error");
574 assert!(!details.retryable);
575 assert_eq!(details.suggestion.as_deref(), Some(AUTH_SUGGESTION));
576 }
577
578 #[test]
579 fn test_error_descriptor_from_message_classifies_other_auth_failures() {
580 for message in [
581 "Unauthorized request for alias 'local'",
582 "Forbidden: bucket access denied",
583 "Authentication failed for alias 'local'",
584 ] {
585 let descriptor = ErrorDescriptor::from_message(message);
586 let json = descriptor.to_json_output();
587 let details = json.details.expect("details should be present");
588
589 assert_eq!(details.error_type, "auth_error", "message: {message}");
590 assert!(!details.retryable, "message: {message}");
591 assert_eq!(
592 details.suggestion.as_deref(),
593 Some(AUTH_SUGGESTION),
594 "message: {message}"
595 );
596 }
597 }
598
599 #[test]
600 fn test_error_descriptor_from_message_infers_conflict() {
601 let descriptor = ErrorDescriptor::from_message("Destination exists: report.json");
602 let json = descriptor.to_json_output();
603
604 let details = json.details.expect("details should be present");
605 assert_eq!(details.error_type, "conflict");
606 assert!(!details.retryable);
607 assert_eq!(details.suggestion.as_deref(), Some(CONFLICT_SUGGESTION));
608 }
609
610 #[test]
611 fn test_error_descriptor_from_message_infers_unsupported_feature() {
612 let descriptor = ErrorDescriptor::from_message(
613 "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
614 );
615 let json = descriptor.to_json_output();
616
617 let details = json.details.expect("details should be present");
618 assert_eq!(details.error_type, "unsupported_feature");
619 assert!(!details.retryable);
620 assert_eq!(details.suggestion.as_deref(), Some(UNSUPPORTED_SUGGESTION));
621 }
622
623 #[test]
624 fn test_error_descriptor_from_message_infers_retryable_network_error() {
625 let descriptor =
626 ErrorDescriptor::from_message("Service temporarily unavailable while connecting");
627 let json = descriptor.to_json_output();
628
629 let details = json.details.expect("details should be present");
630 assert_eq!(details.error_type, "network_error");
631 assert!(details.retryable);
632 assert_eq!(details.suggestion.as_deref(), Some(NETWORK_SUGGESTION));
633 }
634
635 #[test]
636 fn test_error_with_suggestion_overrides_default_hint() {
637 let descriptor = ErrorDescriptor::from_code(
638 ExitCode::UnsupportedFeature,
639 "Backend does not support notifications.",
640 )
641 .with_suggestion("Retry with --force to bypass capability detection.");
642
643 let json = descriptor.to_json_output();
644 let details = json.details.expect("details should be present");
645
646 assert_eq!(details.error_type, "unsupported_feature");
647 assert_eq!(
648 details.suggestion.as_deref(),
649 Some("Retry with --force to bypass capability detection.")
650 );
651 }
652}