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;
10
11/// Color theme for styled output (exa/eza inspired)
12#[derive(Debug, Clone)]
13pub struct Theme {
14    /// Directory names - blue + bold
15    pub dir: Style,
16    /// File names - default
17    pub file: Style,
18    /// File sizes - green
19    pub size: Style,
20    /// Timestamps - dim/dark gray
21    pub date: Style,
22    /// Property keys (stat output) - cyan
23    pub key: Style,
24    /// URLs/endpoints - cyan + underline
25    pub url: Style,
26    /// Alias/bucket names - bold
27    pub name: Style,
28    /// Success messages - green
29    pub success: Style,
30    /// Error messages - red
31    pub error: Style,
32    /// Warning messages - yellow
33    pub warning: Style,
34    /// Tree branch characters - dim
35    pub tree_branch: Style,
36}
37
38impl Default for Theme {
39    fn default() -> Self {
40        Self {
41            dir: Style::new().blue().bold(),
42            file: Style::new(),
43            size: Style::new().green(),
44            date: Style::new().dim(),
45            key: Style::new().cyan(),
46            url: Style::new().cyan().underlined(),
47            name: Style::new().bold(),
48            success: Style::new().green(),
49            error: Style::new().red(),
50            warning: Style::new().yellow(),
51            tree_branch: Style::new().dim(),
52        }
53    }
54}
55
56impl Theme {
57    /// Returns a theme with no styling (for no-color mode)
58    pub fn plain() -> Self {
59        Self {
60            dir: Style::new(),
61            file: Style::new(),
62            size: Style::new(),
63            date: Style::new(),
64            key: Style::new(),
65            url: Style::new(),
66            name: Style::new(),
67            success: Style::new(),
68            error: Style::new(),
69            warning: Style::new(),
70            tree_branch: Style::new(),
71        }
72    }
73}
74
75/// Formatter for CLI output
76///
77/// Handles both human-readable and JSON output formats based on configuration.
78/// When JSON mode is enabled, all output is strict JSON without colors or progress.
79#[derive(Debug, Clone)]
80#[allow(dead_code)]
81pub struct Formatter {
82    config: OutputConfig,
83    theme: Theme,
84}
85
86#[allow(dead_code)]
87impl Formatter {
88    /// Create a new formatter with the given configuration
89    pub fn new(config: OutputConfig) -> Self {
90        let theme = if config.no_color || config.json {
91            Theme::plain()
92        } else {
93            Theme::default()
94        };
95        Self { config, theme }
96    }
97
98    /// Check if JSON output mode is enabled
99    pub fn is_json(&self) -> bool {
100        self.config.json
101    }
102
103    /// Check if quiet mode is enabled
104    pub fn is_quiet(&self) -> bool {
105        self.config.quiet
106    }
107
108    /// Check if colors are enabled
109    pub fn colors_enabled(&self) -> bool {
110        !self.config.no_color && !self.config.json
111    }
112
113    /// Get the current theme
114    pub fn theme(&self) -> &Theme {
115        &self.theme
116    }
117
118    // ========== Style helper methods ==========
119
120    /// Style a directory name (blue + bold)
121    pub fn style_dir(&self, text: &str) -> String {
122        self.theme.dir.apply_to(text).to_string()
123    }
124
125    /// Style a file name (default)
126    pub fn style_file(&self, text: &str) -> String {
127        self.theme.file.apply_to(text).to_string()
128    }
129
130    /// Style a file size (green)
131    pub fn style_size(&self, text: &str) -> String {
132        self.theme.size.apply_to(text).to_string()
133    }
134
135    /// Style a timestamp/date (dim)
136    pub fn style_date(&self, text: &str) -> String {
137        self.theme.date.apply_to(text).to_string()
138    }
139
140    /// Style a property key (cyan)
141    pub fn style_key(&self, text: &str) -> String {
142        self.theme.key.apply_to(text).to_string()
143    }
144
145    /// Style a URL/endpoint (cyan + underline)
146    pub fn style_url(&self, text: &str) -> String {
147        self.theme.url.apply_to(text).to_string()
148    }
149
150    /// Style an alias/bucket name (bold)
151    pub fn style_name(&self, text: &str) -> String {
152        self.theme.name.apply_to(text).to_string()
153    }
154
155    /// Style tree branch characters (dim)
156    pub fn style_tree_branch(&self, text: &str) -> String {
157        self.theme.tree_branch.apply_to(text).to_string()
158    }
159
160    // ========== Output methods ==========
161
162    /// Output a value
163    ///
164    /// In JSON mode, serializes the value to JSON.
165    /// In human mode, uses the Display implementation.
166    pub fn output<T: Serialize + std::fmt::Display>(&self, value: &T) {
167        if self.config.quiet {
168            return;
169        }
170
171        if self.config.json {
172            // JSON output: strict, no colors, no extra formatting
173            match serde_json::to_string_pretty(value) {
174                Ok(json) => println!("{json}"),
175                Err(e) => eprintln!("Error serializing output: {e}"),
176            }
177        } else {
178            println!("{value}");
179        }
180    }
181
182    /// Output a success message
183    pub fn success(&self, message: &str) {
184        if self.config.quiet {
185            return;
186        }
187
188        if self.config.json {
189            // In JSON mode, success is indicated by exit code, not message
190            return;
191        }
192
193        let checkmark = self.theme.success.apply_to("✓");
194        println!("{checkmark} {message}");
195    }
196
197    /// Output an error message
198    ///
199    /// Errors are always printed, even in quiet mode.
200    pub fn error(&self, message: &str) {
201        if self.config.json {
202            let error = serde_json::json!({
203                "error": message
204            });
205            eprintln!(
206                "{}",
207                serde_json::to_string_pretty(&error).unwrap_or_else(|_| message.to_string())
208            );
209        } else {
210            let cross = self.theme.error.apply_to("✗");
211            eprintln!("{cross} {message}");
212        }
213    }
214
215    /// Output a warning message
216    pub fn warning(&self, message: &str) {
217        if self.config.quiet || self.config.json {
218            return;
219        }
220
221        let warn_icon = self.theme.warning.apply_to("⚠");
222        eprintln!("{warn_icon} {message}");
223    }
224
225    /// Output JSON directly
226    ///
227    /// Used when you want to output a pre-built JSON structure.
228    pub fn json<T: Serialize>(&self, value: &T) {
229        match serde_json::to_string_pretty(value) {
230            Ok(json) => println!("{json}"),
231            Err(e) => eprintln!("Error serializing output: {e}"),
232        }
233    }
234
235    /// Print a line of text (respects quiet mode)
236    pub fn println(&self, message: &str) {
237        if self.config.quiet {
238            return;
239        }
240        println!("{message}");
241    }
242}
243
244impl Default for Formatter {
245    fn default() -> Self {
246        Self::new(OutputConfig::default())
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_formatter_default() {
256        let formatter = Formatter::default();
257        assert!(!formatter.is_json());
258        assert!(!formatter.is_quiet());
259        assert!(formatter.colors_enabled());
260    }
261
262    #[test]
263    fn test_formatter_json_mode() {
264        let config = OutputConfig {
265            json: true,
266            ..Default::default()
267        };
268        let formatter = Formatter::new(config);
269        assert!(formatter.is_json());
270        assert!(!formatter.colors_enabled()); // Colors disabled in JSON mode
271    }
272
273    #[test]
274    fn test_formatter_no_color() {
275        let config = OutputConfig {
276            no_color: true,
277            ..Default::default()
278        };
279        let formatter = Formatter::new(config);
280        assert!(!formatter.colors_enabled());
281    }
282}