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("invalid")
125 || normalized.contains("cannot be empty")
126 || normalized.contains("must be")
127 || normalized.contains("must specify")
128 || normalized.contains("expected:")
129 || normalized.contains("use -r/--recursive")
130 {
131 return ("usage_error", false, Some(USAGE_SUGGESTION.to_string()));
132 }
133
134 if normalized.contains("access denied")
135 || normalized.contains("unauthorized")
136 || normalized.contains("forbidden")
137 || normalized.contains("authentication")
138 || normalized.contains("credentials")
139 {
140 return ("auth_error", false, Some(AUTH_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") || normalized.contains("unsupported") {
152 return (
153 "unsupported_feature",
154 false,
155 Some(UNSUPPORTED_SUGGESTION.to_string()),
156 );
157 }
158
159 if normalized.contains("timeout")
160 || normalized.contains("network")
161 || normalized.contains("connection")
162 || normalized.contains("temporarily unavailable")
163 || normalized.contains("failed to create s3 client")
164 {
165 return ("network_error", true, Some(NETWORK_SUGGESTION.to_string()));
166 }
167
168 ("general_error", false, None)
169}
170
171#[derive(Debug, Clone)]
173pub struct Theme {
174 pub dir: Style,
176 pub file: Style,
178 pub size: Style,
180 pub date: Style,
182 pub key: Style,
184 pub url: Style,
186 pub name: Style,
188 pub success: Style,
190 pub error: Style,
192 pub warning: Style,
194 pub tree_branch: Style,
196}
197
198impl Default for Theme {
199 fn default() -> Self {
200 Self {
201 dir: Style::new().blue().bold(),
202 file: Style::new(),
203 size: Style::new().green(),
204 date: Style::new().dim(),
205 key: Style::new().cyan(),
206 url: Style::new().cyan().underlined(),
207 name: Style::new().bold(),
208 success: Style::new().green(),
209 error: Style::new().red(),
210 warning: Style::new().yellow(),
211 tree_branch: Style::new().dim(),
212 }
213 }
214}
215
216impl Theme {
217 pub fn plain() -> Self {
219 Self {
220 dir: Style::new(),
221 file: Style::new(),
222 size: Style::new(),
223 date: Style::new(),
224 key: Style::new(),
225 url: Style::new(),
226 name: Style::new(),
227 success: Style::new(),
228 error: Style::new(),
229 warning: Style::new(),
230 tree_branch: Style::new(),
231 }
232 }
233}
234
235#[derive(Debug, Clone)]
240#[allow(dead_code)]
241pub struct Formatter {
242 config: OutputConfig,
243 theme: Theme,
244}
245
246#[allow(dead_code)]
247impl Formatter {
248 pub fn new(config: OutputConfig) -> Self {
250 let theme = if config.no_color || config.json {
251 Theme::plain()
252 } else {
253 Theme::default()
254 };
255 Self { config, theme }
256 }
257
258 pub fn is_json(&self) -> bool {
260 self.config.json
261 }
262
263 pub fn is_quiet(&self) -> bool {
265 self.config.quiet
266 }
267
268 pub fn colors_enabled(&self) -> bool {
270 !self.config.no_color && !self.config.json
271 }
272
273 pub fn theme(&self) -> &Theme {
275 &self.theme
276 }
277
278 pub fn output_config(&self) -> OutputConfig {
280 self.config.clone()
281 }
282
283 pub fn style_dir(&self, text: &str) -> String {
287 self.theme.dir.apply_to(text).to_string()
288 }
289
290 pub fn style_file(&self, text: &str) -> String {
292 self.theme.file.apply_to(text).to_string()
293 }
294
295 pub fn style_size(&self, text: &str) -> String {
297 self.theme.size.apply_to(text).to_string()
298 }
299
300 pub fn style_date(&self, text: &str) -> String {
302 self.theme.date.apply_to(text).to_string()
303 }
304
305 pub fn style_key(&self, text: &str) -> String {
307 self.theme.key.apply_to(text).to_string()
308 }
309
310 pub fn style_url(&self, text: &str) -> String {
312 self.theme.url.apply_to(text).to_string()
313 }
314
315 pub fn style_name(&self, text: &str) -> String {
317 self.theme.name.apply_to(text).to_string()
318 }
319
320 pub fn style_tree_branch(&self, text: &str) -> String {
322 self.theme.tree_branch.apply_to(text).to_string()
323 }
324
325 pub fn output<T: Serialize + std::fmt::Display>(&self, value: &T) {
332 if self.config.quiet {
333 return;
334 }
335
336 if self.config.json {
337 match serde_json::to_string_pretty(value) {
339 Ok(json) => println!("{json}"),
340 Err(e) => eprintln!("Error serializing output: {e}"),
341 }
342 } else {
343 println!("{value}");
344 }
345 }
346
347 pub fn success(&self, message: &str) {
349 if self.config.quiet {
350 return;
351 }
352
353 if self.config.json {
354 return;
356 }
357
358 let checkmark = self.theme.success.apply_to("✓");
359 println!("{checkmark} {message}");
360 }
361
362 pub fn error(&self, message: &str) {
366 self.emit_error(ErrorDescriptor::from_message(message));
367 }
368
369 pub fn error_with_code(&self, code: ExitCode, message: &str) {
371 self.emit_error(ErrorDescriptor::from_code(code, message));
372 }
373
374 pub fn error_with_suggestion(&self, code: ExitCode, message: &str, suggestion: &str) {
376 self.emit_error(ErrorDescriptor::from_code(code, message).with_suggestion(suggestion));
377 }
378
379 pub fn fail(&self, code: ExitCode, message: &str) -> ExitCode {
381 self.error_with_code(code, message);
382 code
383 }
384
385 pub fn fail_with_suggestion(
387 &self,
388 code: ExitCode,
389 message: &str,
390 suggestion: &str,
391 ) -> ExitCode {
392 self.error_with_suggestion(code, message, suggestion);
393 code
394 }
395
396 fn emit_error(&self, descriptor: ErrorDescriptor) {
397 if self.config.json {
398 let error = descriptor.to_json_output();
399 eprintln!(
400 "{}",
401 serde_json::to_string_pretty(&error).unwrap_or_else(|_| descriptor.message.clone())
402 );
403 } else {
404 let cross = self.theme.error.apply_to("✗");
405 eprintln!("{cross} {}", descriptor.message);
406 }
407 }
408
409 pub fn warning(&self, message: &str) {
411 if self.config.quiet || self.config.json {
412 return;
413 }
414
415 let warn_icon = self.theme.warning.apply_to("⚠");
416 eprintln!("{warn_icon} {message}");
417 }
418
419 pub fn json<T: Serialize>(&self, value: &T) {
423 match serde_json::to_string_pretty(value) {
424 Ok(json) => println!("{json}"),
425 Err(e) => eprintln!("Error serializing output: {e}"),
426 }
427 }
428
429 pub fn println(&self, message: &str) {
431 if self.config.quiet {
432 return;
433 }
434 println!("{message}");
435 }
436}
437
438impl Default for Formatter {
439 fn default() -> Self {
440 Self::new(OutputConfig::default())
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn test_formatter_default() {
450 let formatter = Formatter::default();
451 assert!(!formatter.is_json());
452 assert!(!formatter.is_quiet());
453 assert!(formatter.colors_enabled());
454 }
455
456 #[test]
457 fn test_formatter_json_mode() {
458 let config = OutputConfig {
459 json: true,
460 ..Default::default()
461 };
462 let formatter = Formatter::new(config);
463 assert!(formatter.is_json());
464 assert!(!formatter.colors_enabled()); }
466
467 #[test]
468 fn test_formatter_no_color() {
469 let config = OutputConfig {
470 no_color: true,
471 ..Default::default()
472 };
473 let formatter = Formatter::new(config);
474 assert!(!formatter.colors_enabled());
475 }
476
477 #[test]
478 fn test_error_descriptor_from_code_sets_defaults() {
479 let descriptor =
480 ErrorDescriptor::from_code(ExitCode::NetworkError, "Failed to create S3 client");
481
482 assert_eq!(descriptor.code, Some(ExitCode::NetworkError));
483 assert_eq!(descriptor.error_type, "network_error");
484 assert!(descriptor.retryable);
485 assert_eq!(descriptor.suggestion.as_deref(), Some(NETWORK_SUGGESTION));
486 }
487
488 #[test]
489 fn test_error_descriptor_from_message_infers_not_found() {
490 let descriptor = ErrorDescriptor::from_message("Alias 'local' not found");
491 let json = descriptor.to_json_output();
492
493 assert_eq!(json.error, "Alias 'local' not found");
494 assert_eq!(json.code, None);
495 let details = json.details.expect("details should be present");
496 assert_eq!(details.error_type, "not_found");
497 assert!(!details.retryable);
498 assert_eq!(details.suggestion.as_deref(), Some(NOT_FOUND_SUGGESTION));
499 }
500
501 #[test]
502 fn test_error_descriptor_from_message_prefers_usage_for_invalid_permission() {
503 let descriptor = ErrorDescriptor::from_message("Invalid permission 'download'");
504 let json = descriptor.to_json_output();
505
506 let details = json.details.expect("details should be present");
507 assert_eq!(details.error_type, "usage_error");
508 assert!(!details.retryable);
509 assert_eq!(details.suggestion.as_deref(), Some(USAGE_SUGGESTION));
510 }
511
512 #[test]
513 fn test_error_with_suggestion_overrides_default_hint() {
514 let descriptor = ErrorDescriptor::from_code(
515 ExitCode::UnsupportedFeature,
516 "Backend does not support notifications.",
517 )
518 .with_suggestion("Retry with --force to bypass capability detection.");
519
520 let json = descriptor.to_json_output();
521 let details = json.details.expect("details should be present");
522
523 assert_eq!(details.error_type, "unsupported_feature");
524 assert_eq!(
525 details.suggestion.as_deref(),
526 Some("Retry with --force to bypass capability detection.")
527 );
528 }
529}