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 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/// Color theme for styled output (exa/eza inspired)
185#[derive(Debug, Clone)]
186pub struct Theme {
187    /// Directory names - blue + bold
188    pub dir: Style,
189    /// File names - default
190    pub file: Style,
191    /// File sizes - green
192    pub size: Style,
193    /// Timestamps - dim/dark gray
194    pub date: Style,
195    /// Property keys (stat output) - cyan
196    pub key: Style,
197    /// URLs/endpoints - cyan + underline
198    pub url: Style,
199    /// Alias/bucket names - bold
200    pub name: Style,
201    /// Success messages - green
202    pub success: Style,
203    /// Error messages - red
204    pub error: Style,
205    /// Warning messages - yellow
206    pub warning: Style,
207    /// Tree branch characters - dim
208    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    /// Returns a theme with no styling (for no-color mode)
231    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/// Formatter for CLI output
249///
250/// Handles both human-readable and JSON output formats based on configuration.
251/// When JSON mode is enabled, all output is strict JSON without colors or progress.
252#[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    /// Create a new formatter with the given configuration
262    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    /// Check if JSON output mode is enabled
272    pub fn is_json(&self) -> bool {
273        self.config.json
274    }
275
276    /// Check if quiet mode is enabled
277    pub fn is_quiet(&self) -> bool {
278        self.config.quiet
279    }
280
281    /// Check if colors are enabled
282    pub fn colors_enabled(&self) -> bool {
283        !self.config.no_color && !self.config.json
284    }
285
286    /// Get the current theme
287    pub fn theme(&self) -> &Theme {
288        &self.theme
289    }
290
291    /// Get a clone of the output configuration
292    pub fn output_config(&self) -> OutputConfig {
293        self.config.clone()
294    }
295
296    // ========== Style helper methods ==========
297
298    /// Style a directory name (blue + bold)
299    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    /// Style a file name (default)
307    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    /// Style a file size (green)
315    pub fn style_size(&self, text: &str) -> String {
316        self.theme.size.apply_to(text).to_string()
317    }
318
319    /// Style a timestamp/date (dim)
320    pub fn style_date(&self, text: &str) -> String {
321        self.theme.date.apply_to(text).to_string()
322    }
323
324    /// Style a property key (cyan)
325    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    /// Style a URL/endpoint (cyan + underline)
333    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    /// Style an alias/bucket name (bold)
341    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    /// Style tree branch characters (dim)
374    pub fn style_tree_branch(&self, text: &str) -> String {
375        self.theme.tree_branch.apply_to(text).to_string()
376    }
377
378    // ========== Output methods ==========
379
380    /// Output a value
381    ///
382    /// In JSON mode, serializes the value to JSON.
383    /// In human mode, uses the Display implementation.
384    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            // JSON output: strict, no colors, no extra formatting
391            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    /// Output a success message
401    pub fn success(&self, message: &str) {
402        if self.config.quiet {
403            return;
404        }
405
406        if self.config.json {
407            // In JSON mode, success is indicated by exit code, not message
408            return;
409        }
410
411        let checkmark = self.theme.success.apply_to("✓");
412        println!("{checkmark} {message}");
413    }
414
415    /// Output an error message
416    ///
417    /// Errors are always printed, even in quiet mode.
418    pub fn error(&self, message: &str) {
419        self.emit_error(ErrorDescriptor::from_message(message));
420    }
421
422    /// Output an error message with an explicit exit code.
423    pub fn error_with_code(&self, code: ExitCode, message: &str) {
424        self.emit_error(ErrorDescriptor::from_code(code, message));
425    }
426
427    /// Output an error message with an explicit exit code and recovery suggestion.
428    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    /// Print an error and return the provided exit code.
433    pub fn fail(&self, code: ExitCode, message: &str) -> ExitCode {
434        self.error_with_code(code, message);
435        code
436    }
437
438    /// Print an error with a recovery hint and return the provided exit code.
439    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    /// Output a warning message
463    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    /// Output JSON directly
473    ///
474    /// Used when you want to output a pre-built JSON structure.
475    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    /// Output one compact JSON Lines record to stdout.
483    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    /// Write one compact JSON Lines record to stdout and report output failures.
494    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    /// Output one compact JSON Lines error record to stderr.
503    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    /// Output a pre-built JSON error record on stderr.
511    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    /// Print a line of text (respects quiet mode)
519    pub fn println(&self, message: &str) {
520        if self.config.quiet {
521            return;
522        }
523        println!("{message}");
524    }
525
526    /// Print a line to stdout and report output failures.
527    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()); // Colors disabled in JSON mode
584    }
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}