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