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