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}