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 specify")
137 || normalized.contains("expected:")
138 || normalized.contains("use -r/--recursive")
139 {
140 return ("usage_error", false, Some(USAGE_SUGGESTION.to_string()));
141 }
142
143 if normalized.contains("already exists")
144 || normalized.contains("conflict")
145 || normalized.contains("precondition")
146 || normalized.contains("destination exists")
147 {
148 return ("conflict", false, Some(CONFLICT_SUGGESTION.to_string()));
149 }
150
151 if normalized.contains("does not support")
152 || normalized.contains("unsupported")
153 || normalized.contains("not yet supported")
154 {
155 return (
156 "unsupported_feature",
157 false,
158 Some(UNSUPPORTED_SUGGESTION.to_string()),
159 );
160 }
161
162 if normalized.contains("timeout")
163 || normalized.contains("network")
164 || normalized.contains("connection")
165 || normalized.contains("temporarily unavailable")
166 || normalized.contains("failed to create s3 client")
167 {
168 return ("network_error", true, Some(NETWORK_SUGGESTION.to_string()));
169 }
170
171 ("general_error", false, None)
172}
173
174#[derive(Debug, Clone)]
176pub struct Theme {
177 pub dir: Style,
179 pub file: Style,
181 pub size: Style,
183 pub date: Style,
185 pub key: Style,
187 pub url: Style,
189 pub name: Style,
191 pub success: Style,
193 pub error: Style,
195 pub warning: Style,
197 pub tree_branch: Style,
199}
200
201impl Default for Theme {
202 fn default() -> Self {
203 Self {
204 dir: Style::new().blue().bold(),
205 file: Style::new(),
206 size: Style::new().green(),
207 date: Style::new().dim(),
208 key: Style::new().cyan(),
209 url: Style::new().cyan().underlined(),
210 name: Style::new().bold(),
211 success: Style::new().green(),
212 error: Style::new().red(),
213 warning: Style::new().yellow(),
214 tree_branch: Style::new().dim(),
215 }
216 }
217}
218
219impl Theme {
220 pub fn plain() -> Self {
222 Self {
223 dir: Style::new(),
224 file: Style::new(),
225 size: Style::new(),
226 date: Style::new(),
227 key: Style::new(),
228 url: Style::new(),
229 name: Style::new(),
230 success: Style::new(),
231 error: Style::new(),
232 warning: Style::new(),
233 tree_branch: Style::new(),
234 }
235 }
236}
237
238#[derive(Debug, Clone)]
243#[allow(dead_code)]
244pub struct Formatter {
245 config: OutputConfig,
246 theme: Theme,
247}
248
249#[allow(dead_code)]
250impl Formatter {
251 pub fn new(config: OutputConfig) -> Self {
253 let theme = if config.no_color || config.json {
254 Theme::plain()
255 } else {
256 Theme::default()
257 };
258 Self { config, theme }
259 }
260
261 pub fn is_json(&self) -> bool {
263 self.config.json
264 }
265
266 pub fn is_quiet(&self) -> bool {
268 self.config.quiet
269 }
270
271 pub fn colors_enabled(&self) -> bool {
273 !self.config.no_color && !self.config.json
274 }
275
276 pub fn theme(&self) -> &Theme {
278 &self.theme
279 }
280
281 pub fn output_config(&self) -> OutputConfig {
283 self.config.clone()
284 }
285
286 pub fn style_dir(&self, text: &str) -> String {
290 self.theme.dir.apply_to(text).to_string()
291 }
292
293 pub fn style_file(&self, text: &str) -> String {
295 self.theme.file.apply_to(text).to_string()
296 }
297
298 pub fn style_size(&self, text: &str) -> String {
300 self.theme.size.apply_to(text).to_string()
301 }
302
303 pub fn style_date(&self, text: &str) -> String {
305 self.theme.date.apply_to(text).to_string()
306 }
307
308 pub fn style_key(&self, text: &str) -> String {
310 self.theme.key.apply_to(text).to_string()
311 }
312
313 pub fn style_url(&self, text: &str) -> String {
315 self.theme.url.apply_to(text).to_string()
316 }
317
318 pub fn style_name(&self, text: &str) -> String {
320 self.theme.name.apply_to(text).to_string()
321 }
322
323 pub fn style_tree_branch(&self, text: &str) -> String {
325 self.theme.tree_branch.apply_to(text).to_string()
326 }
327
328 pub fn output<T: Serialize + std::fmt::Display>(&self, value: &T) {
335 if self.config.quiet {
336 return;
337 }
338
339 if self.config.json {
340 match serde_json::to_string_pretty(value) {
342 Ok(json) => println!("{json}"),
343 Err(e) => eprintln!("Error serializing output: {e}"),
344 }
345 } else {
346 println!("{value}");
347 }
348 }
349
350 pub fn success(&self, message: &str) {
352 if self.config.quiet {
353 return;
354 }
355
356 if self.config.json {
357 return;
359 }
360
361 let checkmark = self.theme.success.apply_to("✓");
362 println!("{checkmark} {message}");
363 }
364
365 pub fn error(&self, message: &str) {
369 self.emit_error(ErrorDescriptor::from_message(message));
370 }
371
372 pub fn error_with_code(&self, code: ExitCode, message: &str) {
374 self.emit_error(ErrorDescriptor::from_code(code, message));
375 }
376
377 pub fn error_with_suggestion(&self, code: ExitCode, message: &str, suggestion: &str) {
379 self.emit_error(ErrorDescriptor::from_code(code, message).with_suggestion(suggestion));
380 }
381
382 pub fn fail(&self, code: ExitCode, message: &str) -> ExitCode {
384 self.error_with_code(code, message);
385 code
386 }
387
388 pub fn fail_with_suggestion(
390 &self,
391 code: ExitCode,
392 message: &str,
393 suggestion: &str,
394 ) -> ExitCode {
395 self.error_with_suggestion(code, message, suggestion);
396 code
397 }
398
399 fn emit_error(&self, descriptor: ErrorDescriptor) {
400 if self.config.json {
401 let error = descriptor.to_json_output();
402 eprintln!(
403 "{}",
404 serde_json::to_string_pretty(&error).unwrap_or_else(|_| descriptor.message.clone())
405 );
406 } else {
407 let cross = self.theme.error.apply_to("✗");
408 eprintln!("{cross} {}", descriptor.message);
409 }
410 }
411
412 pub fn warning(&self, message: &str) {
414 if self.config.quiet || self.config.json {
415 return;
416 }
417
418 let warn_icon = self.theme.warning.apply_to("⚠");
419 eprintln!("{warn_icon} {message}");
420 }
421
422 pub fn json<T: Serialize>(&self, value: &T) {
426 match serde_json::to_string_pretty(value) {
427 Ok(json) => println!("{json}"),
428 Err(e) => eprintln!("Error serializing output: {e}"),
429 }
430 }
431
432 pub fn println(&self, message: &str) {
434 if self.config.quiet {
435 return;
436 }
437 println!("{message}");
438 }
439}
440
441impl Default for Formatter {
442 fn default() -> Self {
443 Self::new(OutputConfig::default())
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn test_formatter_default() {
453 let formatter = Formatter::default();
454 assert!(!formatter.is_json());
455 assert!(!formatter.is_quiet());
456 assert!(formatter.colors_enabled());
457 }
458
459 #[test]
460 fn test_formatter_json_mode() {
461 let config = OutputConfig {
462 json: true,
463 ..Default::default()
464 };
465 let formatter = Formatter::new(config);
466 assert!(formatter.is_json());
467 assert!(!formatter.colors_enabled()); }
469
470 #[test]
471 fn test_formatter_no_color() {
472 let config = OutputConfig {
473 no_color: true,
474 ..Default::default()
475 };
476 let formatter = Formatter::new(config);
477 assert!(!formatter.colors_enabled());
478 }
479
480 #[test]
481 fn test_error_descriptor_from_code_sets_defaults() {
482 let descriptor =
483 ErrorDescriptor::from_code(ExitCode::NetworkError, "Failed to create S3 client");
484
485 assert_eq!(descriptor.code, Some(ExitCode::NetworkError));
486 assert_eq!(descriptor.error_type, "network_error");
487 assert!(descriptor.retryable);
488 assert_eq!(descriptor.suggestion.as_deref(), Some(NETWORK_SUGGESTION));
489 }
490
491 #[test]
492 fn test_error_descriptor_from_message_infers_not_found() {
493 let descriptor = ErrorDescriptor::from_message("Alias 'local' not found");
494 let json = descriptor.to_json_output();
495
496 assert_eq!(json.error, "Alias 'local' not found");
497 assert_eq!(json.code, None);
498 let details = json.details.expect("details should be present");
499 assert_eq!(details.error_type, "not_found");
500 assert!(!details.retryable);
501 assert_eq!(details.suggestion.as_deref(), Some(NOT_FOUND_SUGGESTION));
502 }
503
504 #[test]
505 fn test_error_descriptor_from_message_prefers_usage_for_invalid_permission() {
506 let descriptor = ErrorDescriptor::from_message("Invalid permission 'download'");
507 let json = descriptor.to_json_output();
508
509 let details = json.details.expect("details should be present");
510 assert_eq!(details.error_type, "usage_error");
511 assert!(!details.retryable);
512 assert_eq!(details.suggestion.as_deref(), Some(USAGE_SUGGESTION));
513 }
514
515 #[test]
516 fn test_error_descriptor_from_message_prefers_auth_for_invalid_credentials() {
517 let descriptor = ErrorDescriptor::from_message("Invalid credentials for alias 'local'");
518 let json = descriptor.to_json_output();
519
520 let details = json.details.expect("details should be present");
521 assert_eq!(details.error_type, "auth_error");
522 assert!(!details.retryable);
523 assert_eq!(details.suggestion.as_deref(), Some(AUTH_SUGGESTION));
524 }
525
526 #[test]
527 fn test_error_descriptor_from_message_classifies_other_auth_failures() {
528 for message in [
529 "Unauthorized request for alias 'local'",
530 "Forbidden: bucket access denied",
531 "Authentication failed for alias 'local'",
532 ] {
533 let descriptor = ErrorDescriptor::from_message(message);
534 let json = descriptor.to_json_output();
535 let details = json.details.expect("details should be present");
536
537 assert_eq!(details.error_type, "auth_error", "message: {message}");
538 assert!(!details.retryable, "message: {message}");
539 assert_eq!(
540 details.suggestion.as_deref(),
541 Some(AUTH_SUGGESTION),
542 "message: {message}"
543 );
544 }
545 }
546
547 #[test]
548 fn test_error_descriptor_from_message_infers_conflict() {
549 let descriptor = ErrorDescriptor::from_message("Destination exists: report.json");
550 let json = descriptor.to_json_output();
551
552 let details = json.details.expect("details should be present");
553 assert_eq!(details.error_type, "conflict");
554 assert!(!details.retryable);
555 assert_eq!(details.suggestion.as_deref(), Some(CONFLICT_SUGGESTION));
556 }
557
558 #[test]
559 fn test_error_descriptor_from_message_infers_unsupported_feature() {
560 let descriptor = ErrorDescriptor::from_message(
561 "Cross-alias S3-to-S3 copy not yet supported. Use download + upload.",
562 );
563 let json = descriptor.to_json_output();
564
565 let details = json.details.expect("details should be present");
566 assert_eq!(details.error_type, "unsupported_feature");
567 assert!(!details.retryable);
568 assert_eq!(details.suggestion.as_deref(), Some(UNSUPPORTED_SUGGESTION));
569 }
570
571 #[test]
572 fn test_error_descriptor_from_message_infers_retryable_network_error() {
573 let descriptor =
574 ErrorDescriptor::from_message("Service temporarily unavailable while connecting");
575 let json = descriptor.to_json_output();
576
577 let details = json.details.expect("details should be present");
578 assert_eq!(details.error_type, "network_error");
579 assert!(details.retryable);
580 assert_eq!(details.suggestion.as_deref(), Some(NETWORK_SUGGESTION));
581 }
582
583 #[test]
584 fn test_error_with_suggestion_overrides_default_hint() {
585 let descriptor = ErrorDescriptor::from_code(
586 ExitCode::UnsupportedFeature,
587 "Backend does not support notifications.",
588 )
589 .with_suggestion("Retry with --force to bypass capability detection.");
590
591 let json = descriptor.to_json_output();
592 let details = json.details.expect("details should be present");
593
594 assert_eq!(details.error_type, "unsupported_feature");
595 assert_eq!(
596 details.suggestion.as_deref(),
597 Some("Retry with --force to bypass capability detection.")
598 );
599 }
600}