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 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/// Color theme for styled output (exa/eza inspired)
177#[derive(Debug, Clone)]
178pub struct Theme {
179    /// Directory names - blue + bold
180    pub dir: Style,
181    /// File names - default
182    pub file: Style,
183    /// File sizes - green
184    pub size: Style,
185    /// Timestamps - dim/dark gray
186    pub date: Style,
187    /// Property keys (stat output) - cyan
188    pub key: Style,
189    /// URLs/endpoints - cyan + underline
190    pub url: Style,
191    /// Alias/bucket names - bold
192    pub name: Style,
193    /// Success messages - green
194    pub success: Style,
195    /// Error messages - red
196    pub error: Style,
197    /// Warning messages - yellow
198    pub warning: Style,
199    /// Tree branch characters - dim
200    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    /// Returns a theme with no styling (for no-color mode)
223    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/// Formatter for CLI output
241///
242/// Handles both human-readable and JSON output formats based on configuration.
243/// When JSON mode is enabled, all output is strict JSON without colors or progress.
244#[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    /// Create a new formatter with the given configuration
254    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    /// Check if JSON output mode is enabled
264    pub fn is_json(&self) -> bool {
265        self.config.json
266    }
267
268    /// Check if quiet mode is enabled
269    pub fn is_quiet(&self) -> bool {
270        self.config.quiet
271    }
272
273    /// Check if colors are enabled
274    pub fn colors_enabled(&self) -> bool {
275        !self.config.no_color && !self.config.json
276    }
277
278    /// Get the current theme
279    pub fn theme(&self) -> &Theme {
280        &self.theme
281    }
282
283    /// Get a clone of the output configuration
284    pub fn output_config(&self) -> OutputConfig {
285        self.config.clone()
286    }
287
288    // ========== Style helper methods ==========
289
290    /// Style a directory name (blue + bold)
291    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    /// Style a file name (default)
299    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    /// Style a file size (green)
307    pub fn style_size(&self, text: &str) -> String {
308        self.theme.size.apply_to(text).to_string()
309    }
310
311    /// Style a timestamp/date (dim)
312    pub fn style_date(&self, text: &str) -> String {
313        self.theme.date.apply_to(text).to_string()
314    }
315
316    /// Style a property key (cyan)
317    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    /// Style a URL/endpoint (cyan + underline)
325    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    /// Style an alias/bucket name (bold)
333    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    /// Style tree branch characters (dim)
366    pub fn style_tree_branch(&self, text: &str) -> String {
367        self.theme.tree_branch.apply_to(text).to_string()
368    }
369
370    // ========== Output methods ==========
371
372    /// Output a value
373    ///
374    /// In JSON mode, serializes the value to JSON.
375    /// In human mode, uses the Display implementation.
376    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            // JSON output: strict, no colors, no extra formatting
383            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    /// Output a success message
393    pub fn success(&self, message: &str) {
394        if self.config.quiet {
395            return;
396        }
397
398        if self.config.json {
399            // In JSON mode, success is indicated by exit code, not message
400            return;
401        }
402
403        let checkmark = self.theme.success.apply_to("✓");
404        println!("{checkmark} {message}");
405    }
406
407    /// Output an error message
408    ///
409    /// Errors are always printed, even in quiet mode.
410    pub fn error(&self, message: &str) {
411        self.emit_error(ErrorDescriptor::from_message(message));
412    }
413
414    /// Output an error message with an explicit exit code.
415    pub fn error_with_code(&self, code: ExitCode, message: &str) {
416        self.emit_error(ErrorDescriptor::from_code(code, message));
417    }
418
419    /// Output an error message with an explicit exit code and recovery suggestion.
420    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    /// Print an error and return the provided exit code.
425    pub fn fail(&self, code: ExitCode, message: &str) -> ExitCode {
426        self.error_with_code(code, message);
427        code
428    }
429
430    /// Print an error with a recovery hint and return the provided exit code.
431    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    /// Output a warning message
455    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    /// Output JSON directly
465    ///
466    /// Used when you want to output a pre-built JSON structure.
467    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    /// Print a line of text (respects quiet mode)
475    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()); // Colors disabled in JSON mode
510    }
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}