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    /// Get a clone of the output configuration
119    pub fn output_config(&self) -> OutputConfig {
120        self.config.clone()
121    }
122
123    // ========== Style helper methods ==========
124
125    /// Style a directory name (blue + bold)
126    pub fn style_dir(&self, text: &str) -> String {
127        self.theme.dir.apply_to(text).to_string()
128    }
129
130    /// Style a file name (default)
131    pub fn style_file(&self, text: &str) -> String {
132        self.theme.file.apply_to(text).to_string()
133    }
134
135    /// Style a file size (green)
136    pub fn style_size(&self, text: &str) -> String {
137        self.theme.size.apply_to(text).to_string()
138    }
139
140    /// Style a timestamp/date (dim)
141    pub fn style_date(&self, text: &str) -> String {
142        self.theme.date.apply_to(text).to_string()
143    }
144
145    /// Style a property key (cyan)
146    pub fn style_key(&self, text: &str) -> String {
147        self.theme.key.apply_to(text).to_string()
148    }
149
150    /// Style a URL/endpoint (cyan + underline)
151    pub fn style_url(&self, text: &str) -> String {
152        self.theme.url.apply_to(text).to_string()
153    }
154
155    /// Style an alias/bucket name (bold)
156    pub fn style_name(&self, text: &str) -> String {
157        self.theme.name.apply_to(text).to_string()
158    }
159
160    /// Style tree branch characters (dim)
161    pub fn style_tree_branch(&self, text: &str) -> String {
162        self.theme.tree_branch.apply_to(text).to_string()
163    }
164
165    // ========== Output methods ==========
166
167    /// Output a value
168    ///
169    /// In JSON mode, serializes the value to JSON.
170    /// In human mode, uses the Display implementation.
171    pub fn output<T: Serialize + std::fmt::Display>(&self, value: &T) {
172        if self.config.quiet {
173            return;
174        }
175
176        if self.config.json {
177            // JSON output: strict, no colors, no extra formatting
178            match serde_json::to_string_pretty(value) {
179                Ok(json) => println!("{json}"),
180                Err(e) => eprintln!("Error serializing output: {e}"),
181            }
182        } else {
183            println!("{value}");
184        }
185    }
186
187    /// Output a success message
188    pub fn success(&self, message: &str) {
189        if self.config.quiet {
190            return;
191        }
192
193        if self.config.json {
194            // In JSON mode, success is indicated by exit code, not message
195            return;
196        }
197
198        let checkmark = self.theme.success.apply_to("✓");
199        println!("{checkmark} {message}");
200    }
201
202    /// Output an error message
203    ///
204    /// Errors are always printed, even in quiet mode.
205    pub fn error(&self, message: &str) {
206        if self.config.json {
207            let error = serde_json::json!({
208                "error": message
209            });
210            eprintln!(
211                "{}",
212                serde_json::to_string_pretty(&error).unwrap_or_else(|_| message.to_string())
213            );
214        } else {
215            let cross = self.theme.error.apply_to("✗");
216            eprintln!("{cross} {message}");
217        }
218    }
219
220    /// Output a warning message
221    pub fn warning(&self, message: &str) {
222        if self.config.quiet || self.config.json {
223            return;
224        }
225
226        let warn_icon = self.theme.warning.apply_to("⚠");
227        eprintln!("{warn_icon} {message}");
228    }
229
230    /// Output JSON directly
231    ///
232    /// Used when you want to output a pre-built JSON structure.
233    pub fn json<T: Serialize>(&self, value: &T) {
234        match serde_json::to_string_pretty(value) {
235            Ok(json) => println!("{json}"),
236            Err(e) => eprintln!("Error serializing output: {e}"),
237        }
238    }
239
240    /// Print a line of text (respects quiet mode)
241    pub fn println(&self, message: &str) {
242        if self.config.quiet {
243            return;
244        }
245        println!("{message}");
246    }
247}
248
249impl Default for Formatter {
250    fn default() -> Self {
251        Self::new(OutputConfig::default())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_formatter_default() {
261        let formatter = Formatter::default();
262        assert!(!formatter.is_json());
263        assert!(!formatter.is_quiet());
264        assert!(formatter.colors_enabled());
265    }
266
267    #[test]
268    fn test_formatter_json_mode() {
269        let config = OutputConfig {
270            json: true,
271            ..Default::default()
272        };
273        let formatter = Formatter::new(config);
274        assert!(formatter.is_json());
275        assert!(!formatter.colors_enabled()); // Colors disabled in JSON mode
276    }
277
278    #[test]
279    fn test_formatter_no_color() {
280        let config = OutputConfig {
281            no_color: true,
282            ..Default::default()
283        };
284        let formatter = Formatter::new(config);
285        assert!(!formatter.colors_enabled());
286    }
287}