Skip to main content

netspeed_cli/config/
output.rs

1//! Output format and display configuration types.
2
3use crate::theme::Theme;
4
5use super::{File, OutputSource};
6
7/// Output format selection — config-internal domain type.
8///
9/// Decoupled from [`crate::cli::OutputFormatType`] (which carries clap's `ValueEnum`
10/// derive). The CLI enum is converted into this type at the config boundary via
11/// `Format::from_cli_type`, so the config layer never depends on the CLI crate.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Format {
14    /// Machine-readable JSON output
15    Json,
16    /// JSON Lines for logging (one JSON object per line)
17    Jsonl,
18    /// CSV format for spreadsheet analysis
19    Csv,
20    /// Ultra-minimal: just grade + speeds
21    Minimal,
22    /// Minimal one-line summary
23    Simple,
24    /// Key metrics with quality ratings
25    Compact,
26    /// Full analysis with per-metric grades (default)
27    Detailed,
28    /// Rich terminal dashboard with capability matrix
29    Dashboard,
30}
31
32impl Format {
33    /// Convert from the CLI-specific [`crate::cli::OutputFormatType`] enum.
34    ///
35    /// This is the only place the config layer touches the CLI type —
36    /// all downstream consumers use [`Format`] instead.
37    ///
38    /// # Example
39    ///
40    /// ```ignore
41    /// use netspeed_cli::config::Format;
42    ///
43    /// // Convert CLI enum to config-internal enum
44    /// let fmt = Format::from_cli_type(netspeed_cli::cli::OutputFormatType::Json);
45    /// assert_eq!(fmt, Format::Json);
46    ///
47    /// let fmt = Format::from_cli_type(netspeed_cli::cli::OutputFormatType::Dashboard);
48    /// assert_eq!(fmt, Format::Dashboard);
49    /// ```
50    #[must_use]
51    pub(crate) fn from_cli_type(cli: crate::cli::OutputFormatType) -> Self {
52        match cli {
53            crate::cli::OutputFormatType::Json => Self::Json,
54            crate::cli::OutputFormatType::Jsonl => Self::Jsonl,
55            crate::cli::OutputFormatType::Csv => Self::Csv,
56            crate::cli::OutputFormatType::Minimal => Self::Minimal,
57            crate::cli::OutputFormatType::Simple => Self::Simple,
58            crate::cli::OutputFormatType::Compact => Self::Compact,
59            crate::cli::OutputFormatType::Detailed => Self::Detailed,
60            crate::cli::OutputFormatType::Dashboard => Self::Dashboard,
61        }
62    }
63
64    /// Whether this format is machine-readable (JSON/JSONL/CSV).
65    ///
66    /// # Example
67    ///
68    /// ```
69    /// use netspeed_cli::config::Format;
70    ///
71    /// // JSON, JSONL, and CSV are machine-readable
72    /// assert!(Format::Json.is_machine_readable());
73    /// assert!(Format::Jsonl.is_machine_readable());
74    /// assert!(Format::Csv.is_machine_readable());
75    ///
76    /// // All other formats are human-readable only
77    /// for fmt in [Format::Minimal, Format::Simple, Format::Compact,
78    ///             Format::Detailed, Format::Dashboard] {
79    ///     assert!(!fmt.is_machine_readable());
80    /// }
81    /// ```
82    #[must_use]
83    pub fn is_machine_readable(self) -> bool {
84        matches!(self, Self::Json | Self::Jsonl | Self::Csv)
85    }
86
87    /// Whether this format produces non-verbose (terse) output.
88    ///
89    /// All formats except [`Detailed`](Format::Detailed) are considered non-verbose.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use netspeed_cli::config::Format;
95    ///
96    /// // Detailed is the only verbose format
97    /// assert!(!Format::Detailed.is_non_verbose());
98    ///
99    /// // Everything else is non-verbose (terse)
100    /// for fmt in [Format::Simple, Format::Minimal, Format::Compact,
101    ///             Format::Json, Format::Jsonl, Format::Csv,
102    ///             Format::Dashboard] {
103    ///     assert!(fmt.is_non_verbose());
104    /// }
105    /// ```
106    #[must_use]
107    pub fn is_non_verbose(self) -> bool {
108        matches!(
109            self,
110            Self::Simple
111                | Self::Minimal
112                | Self::Compact
113                | Self::Json
114                | Self::Jsonl
115                | Self::Csv
116                | Self::Dashboard
117        )
118    }
119
120    /// Human-readable label for display (e.g., dry-run output).
121    ///
122    /// # Example
123    ///
124    /// ```
125    /// use netspeed_cli::config::Format;
126    ///
127    /// assert_eq!(Format::Json.label(), "JSON");
128    /// assert_eq!(Format::Dashboard.label(), "Dashboard");
129    /// assert_eq!(Format::Compact.label(), "Compact");
130    ///
131    /// // Labels are also used via Display trait
132    /// let csv = Format::Csv;
133    /// assert_eq!(format!("{csv}"), "CSV");
134    /// ```
135    #[must_use]
136    pub fn label(self) -> &'static str {
137        match self {
138            Self::Json => "JSON",
139            Self::Jsonl => "JSONL",
140            Self::Csv => "CSV",
141            Self::Minimal => "Minimal",
142            Self::Simple => "Simple",
143            Self::Compact => "Compact",
144            Self::Detailed => "Detailed",
145            Self::Dashboard => "Dashboard",
146        }
147    }
148}
149
150impl std::fmt::Display for Format {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.write_str(self.label())
153    }
154}
155
156/// Output and display configuration.
157/// Controls how test results are formatted and presented to the user.
158///
159/// # Example
160///
161/// ```ignore
162/// use netspeed_cli::config::{Format, OutputConfig, OutputSource, File};
163///
164/// let source = OutputSource {
165///     format: Some(Format::Json),
166///     quiet: Some(true),
167///     ..Default::default()
168/// };
169/// let file_config = File::default();
170/// let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
171///
172/// let output = OutputConfig::from_source(&source, &file_config, merge_bool);
173/// assert!(output.quiet);
174/// assert_eq!(output.csv_delimiter, ','); // business-logic default preserved
175/// ```
176#[derive(Debug, Clone)]
177pub struct OutputConfig {
178    /// Display values in bytes instead of bits
179    pub bytes: bool,
180    /// Suppress verbose output (deprecated, use format)
181    pub simple: bool,
182    /// Output in CSV format (deprecated, use format)
183    pub csv: bool,
184    /// CSV field delimiter
185    pub csv_delimiter: char,
186    /// Include CSV headers
187    pub csv_header: bool,
188    /// Output in JSON format (deprecated, use format)
189    pub json: bool,
190    /// Display server list and exit
191    pub list: bool,
192    /// Suppress all progress output
193    pub quiet: bool,
194    /// User profile for customized output
195    pub profile: Option<String>,
196    /// Color theme for terminal output
197    pub theme: Theme,
198    /// Minimal ASCII-only output (no Unicode box-drawing)
199    pub minimal: bool,
200    /// Output format (supersedes legacy --json/--csv/--simple)
201    pub format: Option<Format>,
202}
203
204// OutputConfig has a manual Default impl to preserve the business-logic default
205// for csv_delimiter (',') rather than char's default ('\0').
206impl Default for OutputConfig {
207    fn default() -> Self {
208        Self {
209            bytes: false,
210            simple: false,
211            csv: false,
212            csv_delimiter: ',',
213            csv_header: false,
214            json: false,
215            list: false,
216            quiet: false,
217            profile: None,
218            theme: Theme::Dark,
219            minimal: false,
220            format: None,
221        }
222    }
223}
224
225impl OutputConfig {
226    /// Convert to merged output config from CLI source and file config.
227    #[must_use]
228    #[allow(deprecated)]
229    pub(crate) fn from_source(
230        source: &OutputSource,
231        file_config: &File,
232        merge_bool: impl Fn(Option<bool>, Option<bool>) -> bool,
233    ) -> Self {
234        let theme = if source.theme == "dark" {
235            file_config
236                .theme
237                .as_ref()
238                .and_then(|t| Theme::from_name(t))
239                .unwrap_or_default()
240        } else {
241            Theme::from_name(&source.theme).unwrap_or_default()
242        };
243
244        Self {
245            bytes: merge_bool(source.bytes, file_config.bytes),
246            simple: merge_bool(source.simple, file_config.simple),
247            csv: merge_bool(source.csv, file_config.csv),
248            csv_delimiter: if source.csv_delimiter == ',' {
249                file_config.csv_delimiter.unwrap_or(',')
250            } else {
251                source.csv_delimiter
252            },
253            csv_header: merge_bool(source.csv_header, file_config.csv_header),
254            json: merge_bool(source.json, file_config.json),
255            list: source.list,
256            quiet: merge_bool(source.quiet, None),
257            profile: source.profile.clone().or(file_config.profile.clone()),
258            theme,
259            minimal: merge_bool(source.minimal, None),
260            format: source.format,
261        }
262    }
263}