Skip to main content

rustfs_cli/output/
formatter.rs

1//! Output formatter for human-readable and JSON output
2//!
3//! Ensures consistent output formatting across all commands.
4//! JSON output follows the schema defined in schemas/output_v1.json.
5
6use 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/// Color theme for styled output (exa/eza inspired)
175#[derive(Debug, Clone)]
176pub struct Theme {
177    /// Directory names - blue + bold
178    pub dir: Style,
179    /// File names - default
180    pub file: Style,
181    /// File sizes - green
182    pub size: Style,
183    /// Timestamps - dim/dark gray
184    pub date: Style,
185    /// Property keys (stat output) - cyan
186    pub key: Style,
187    /// URLs/endpoints - cyan + underline
188    pub url: Style,
189    /// Alias/bucket names - bold
190    pub name: Style,
191    /// Success messages - green
192    pub success: Style,
193    /// Error messages - red
194    pub error: Style,
195    /// Warning messages - yellow
196    pub warning: Style,
197    /// Tree branch characters - dim
198    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    /// Returns a theme with no styling (for no-color mode)
221    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/// Formatter for CLI output
239///
240/// Handles both human-readable and JSON output formats based on configuration.
241/// When JSON mode is enabled, all output is strict JSON without colors or progress.
242#[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    /// Create a new formatter with the given configuration
252    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    /// Check if JSON output mode is enabled
262    pub fn is_json(&self) -> bool {
263        self.config.json
264    }
265
266    /// Check if quiet mode is enabled
267    pub fn is_quiet(&self) -> bool {
268        self.config.quiet
269    }
270
271    /// Check if colors are enabled
272    pub fn colors_enabled(&self) -> bool {
273        !self.config.no_color && !self.config.json
274    }
275
276    /// Get the current theme
277    pub fn theme(&self) -> &Theme {
278        &self.theme
279    }
280
281    /// Get a clone of the output configuration
282    pub fn output_config(&self) -> OutputConfig {
283        self.config.clone()
284    }
285
286    // ========== Style helper methods ==========
287
288    /// Style a directory name (blue + bold)
289    pub fn style_dir(&self, text: &str) -> String {
290        self.theme.dir.apply_to(text).to_string()
291    }
292
293    /// Style a file name (default)
294    pub fn style_file(&self, text: &str) -> String {
295        self.theme.file.apply_to(text).to_string()
296    }
297
298    /// Style a file size (green)
299    pub fn style_size(&self, text: &str) -> String {
300        self.theme.size.apply_to(text).to_string()
301    }
302
303    /// Style a timestamp/date (dim)
304    pub fn style_date(&self, text: &str) -> String {
305        self.theme.date.apply_to(text).to_string()
306    }
307
308    /// Style a property key (cyan)
309    pub fn style_key(&self, text: &str) -> String {
310        self.theme.key.apply_to(text).to_string()
311    }
312
313    /// Style a URL/endpoint (cyan + underline)
314    pub fn style_url(&self, text: &str) -> String {
315        self.theme.url.apply_to(text).to_string()
316    }
317
318    /// Style an alias/bucket name (bold)
319    pub fn style_name(&self, text: &str) -> String {
320        self.theme.name.apply_to(text).to_string()
321    }
322
323    /// Style tree branch characters (dim)
324    pub fn style_tree_branch(&self, text: &str) -> String {
325        self.theme.tree_branch.apply_to(text).to_string()
326    }
327
328    // ========== Output methods ==========
329
330    /// Output a value
331    ///
332    /// In JSON mode, serializes the value to JSON.
333    /// In human mode, uses the Display implementation.
334    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            // JSON output: strict, no colors, no extra formatting
341            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    /// Output a success message
351    pub fn success(&self, message: &str) {
352        if self.config.quiet {
353            return;
354        }
355
356        if self.config.json {
357            // In JSON mode, success is indicated by exit code, not message
358            return;
359        }
360
361        let checkmark = self.theme.success.apply_to("✓");
362        println!("{checkmark} {message}");
363    }
364
365    /// Output an error message
366    ///
367    /// Errors are always printed, even in quiet mode.
368    pub fn error(&self, message: &str) {
369        self.emit_error(ErrorDescriptor::from_message(message));
370    }
371
372    /// Output an error message with an explicit exit code.
373    pub fn error_with_code(&self, code: ExitCode, message: &str) {
374        self.emit_error(ErrorDescriptor::from_code(code, message));
375    }
376
377    /// Output an error message with an explicit exit code and recovery suggestion.
378    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    /// Print an error and return the provided exit code.
383    pub fn fail(&self, code: ExitCode, message: &str) -> ExitCode {
384        self.error_with_code(code, message);
385        code
386    }
387
388    /// Print an error with a recovery hint and return the provided exit code.
389    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    /// Output a warning message
413    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    /// Output JSON directly
423    ///
424    /// Used when you want to output a pre-built JSON structure.
425    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    /// Print a line of text (respects quiet mode)
433    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()); // Colors disabled in JSON mode
468    }
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}