netspeed_cli/config/mod.rs
1use crate::theme::Theme;
2use serde::Deserialize;
3use std::path::PathBuf;
4
5pub mod output;
6pub mod source;
7pub mod validate;
8
9pub use output::{Format, OutputConfig};
10pub use source::{ConfigSource, NetworkSource, OutputSource, ServerSource, TestSource};
11pub use validate::{ValidationResult, get_config_path_internal, load_config_file, validate_config};
12
13// ============================================================================
14// Semantic config sub-structs (SRP: each struct has single responsibility)
15// ============================================================================
16
17/// Test execution configuration.
18/// Controls which tests run and how (single vs multi-stream).
19///
20/// Named `TestSelection` to avoid collision with [`crate::test_config::TestConfig`]
21/// which controls bandwidth measurement parameters (rounds, streams, payloads).
22///
23/// # Example
24///
25/// ```ignore
26/// use netspeed_cli::config::{TestSelection, TestSource, File};
27///
28/// let source = TestSource {
29/// no_download: Some(true),
30/// single: Some(true),
31/// ..Default::default()
32/// };
33/// let file_config = File::default();
34/// let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
35///
36/// let test = TestSelection::from_source(&source, &file_config, merge_bool);
37/// assert!(test.no_download);
38/// assert!(test.single);
39/// assert!(!test.no_upload); // unset → false default
40/// ```
41#[derive(Debug, Clone, Default)]
42pub struct TestSelection {
43 /// Do not perform download test
44 pub no_download: bool,
45 /// Do not perform upload test
46 pub no_upload: bool,
47 /// Use single connection instead of multiple
48 pub single: bool,
49}
50
51impl TestSelection {
52 /// Convert to merged test selection from CLI source and file config.
53 #[must_use]
54 pub(crate) fn from_source(
55 source: &TestSource,
56 file_config: &File,
57 merge_bool: impl Fn(Option<bool>, Option<bool>) -> bool,
58 ) -> Self {
59 Self {
60 no_download: merge_bool(source.no_download, file_config.no_download),
61 no_upload: merge_bool(source.no_upload, file_config.no_upload),
62 single: merge_bool(source.single, file_config.single),
63 }
64 }
65}
66
67/// Network and transport configuration.
68/// Controls connection behavior, timeouts, and TLS settings.
69///
70/// # Example
71///
72/// ```ignore
73/// use netspeed_cli::config::{NetworkConfig, NetworkSource, File};
74///
75/// let source = NetworkSource {
76/// timeout: 30,
77/// tls_version: Some("1.3".to_string()),
78/// pin_certs: Some(true),
79/// ..Default::default()
80/// };
81/// let file_config = File::default();
82/// let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
83/// let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
84/// if cli == default { file.unwrap_or(default) } else { cli }
85/// };
86///
87/// let network = NetworkConfig::from_source(&source, &file_config, merge_bool, merge_u64);
88/// assert_eq!(network.timeout, 30);
89/// assert_eq!(network.tls_version, Some("1.3".to_string()));
90/// assert!(network.pin_certs);
91/// assert!(network.source.is_none()); // unset → None default
92/// ```
93#[derive(Debug, Clone)]
94pub struct NetworkConfig {
95 /// Source IP address to bind to
96 pub source: Option<String>,
97 /// HTTP request timeout in seconds
98 pub timeout: u64,
99 /// Path to custom CA certificate for TLS
100 pub ca_cert: Option<String>,
101 /// Minimum TLS version (1.2 or 1.3)
102 pub tls_version: Option<String>,
103 /// Restrict TLS connections to speedtest.net and ookla.com domains.
104 pub pin_certs: bool,
105}
106
107impl Default for NetworkConfig {
108 fn default() -> Self {
109 Self {
110 source: None,
111 timeout: 10,
112 ca_cert: None,
113 tls_version: None,
114 pin_certs: false,
115 }
116 }
117}
118
119impl NetworkConfig {
120 /// Convert from CLI source and file config to merged network config.
121 #[must_use]
122 pub(crate) fn from_source(
123 source: &NetworkSource,
124 file_config: &File,
125 merge_bool: impl Fn(Option<bool>, Option<bool>) -> bool,
126 merge_u64: impl Fn(u64, Option<u64>, u64) -> u64,
127 ) -> Self {
128 Self {
129 source: source.source.clone(),
130 timeout: merge_u64(source.timeout, file_config.timeout, 10),
131 ca_cert: source.ca_cert.clone().or(file_config.ca_cert.clone()),
132 tls_version: source
133 .tls_version
134 .clone()
135 .or(file_config.tls_version.clone()),
136 pin_certs: merge_bool(source.pin_certs, file_config.pin_certs),
137 }
138 }
139}
140
141/// Server selection configuration.
142/// Controls which speedtest.net servers are used.
143///
144/// # Example
145///
146/// ```ignore
147/// use netspeed_cli::config::{ServerSelection, ServerSource};
148///
149/// let source = ServerSource {
150/// server_ids: vec!["1234".to_string(), "5678".to_string()],
151/// exclude_ids: vec!["9999".to_string()],
152/// };
153///
154/// let servers = ServerSelection::from_source(&source);
155/// assert_eq!(servers.server_ids, vec!["1234", "5678"]);
156/// assert_eq!(servers.exclude_ids, vec!["9999"]);
157/// ```
158#[derive(Debug, Clone, Default)]
159pub struct ServerSelection {
160 /// Specific server IDs to use (empty = auto-select)
161 pub server_ids: Vec<String>,
162 /// Server IDs to exclude from selection
163 pub exclude_ids: Vec<String>,
164}
165
166impl ServerSelection {
167 /// Create from CLI source.
168 #[must_use]
169 pub(crate) fn from_source(source: &ServerSource) -> Self {
170 Self {
171 server_ids: source.server_ids.clone(),
172 exclude_ids: source.exclude_ids.clone(),
173 }
174 }
175}
176
177// ============================================================================
178// Main Config struct (composition of sub-structs)
179// ============================================================================
180
181#[derive(Debug, Default, Clone, Deserialize)]
182pub struct File {
183 pub no_download: Option<bool>,
184 pub no_upload: Option<bool>,
185 pub single: Option<bool>,
186 pub bytes: Option<bool>,
187 pub simple: Option<bool>,
188 pub csv: Option<bool>,
189 pub csv_delimiter: Option<char>,
190 pub csv_header: Option<bool>,
191 pub json: Option<bool>,
192 pub timeout: Option<u64>,
193 pub profile: Option<String>,
194 pub theme: Option<String>,
195 /// Custom user agent string (optional, defaults to browser-like UA).
196 pub custom_user_agent: Option<String>,
197 /// Enable strict config mode - invalid values cause warnings.
198 pub strict: Option<bool>,
199 /// Path to a custom CA certificate file for TLS verification.
200 pub ca_cert: Option<String>,
201 /// Minimum TLS version (1.2 or 1.3).
202 pub tls_version: Option<String>,
203 /// Restrict TLS connections to speedtest.net and ookla.com domains.
204 pub pin_certs: Option<bool>,
205}
206
207/// Main configuration struct composed of semantic sub-structs.
208///
209/// Groups related configuration into cohesive units for better code organization:
210/// - `OutputConfig` — output and display settings
211/// - `TestSelection` — test execution controls
212/// - `NetworkConfig` — network and TLS settings
213/// - `ServerSelection` — server filtering options
214
215#[derive(Debug, Clone, Default)]
216pub struct Config {
217 /// Output and display configuration
218 pub output: OutputConfig,
219 /// Test execution controls
220 pub test: TestSelection,
221 /// Network and transport configuration
222 pub network: NetworkConfig,
223 /// Server selection criteria
224 pub servers: ServerSelection,
225 /// Custom user agent (file config only, not CLI)
226 pub custom_user_agent: Option<String>,
227 /// Strict validation mode
228 pub strict: bool,
229}
230
231// ConfigProvider trait exposing read‑only config
232pub trait ConfigProvider: Send + Sync {
233 fn config(&self) -> &Config;
234}
235
236impl ConfigProvider for Config {
237 fn config(&self) -> &Config {
238 self
239 }
240}
241
242impl Config {
243 /// Build configuration from parsed CLI arguments and the config file.
244 ///
245 /// Converts `Args` into [`ConfigSource`] first (the sole CLI→config bridge),
246 /// then builds sub-structs from the source abstraction.
247 ///
248 /// **Note:** This method does NOT call [`validate_and_report()`](Self::validate_and_report).
249 /// For validation with warnings/errors, use
250 /// [`from_args_with_file()`](Self::from_args_with_file) instead, which also
251 /// avoids loading the config file twice.
252 ///
253 /// # Example
254 ///
255 /// ```ignore
256 /// use clap::Parser;
257 /// use netspeed_cli::cli::Args;
258 /// use netspeed_cli::config::{Config, Format};
259 ///
260 /// // Parse CLI arguments (same as the main entry point)
261 /// let args = Args::parse_from(["netspeed-cli", "--format", "json", "--timeout", "30"]);
262 ///
263 /// let config = Config::from_args(&args);
264 /// assert_eq!(config.timeout(), 30);
265 /// assert_eq!(config.format(), Some(Format::Json));
266 /// assert!(!config.no_download()); // unset flags default to false
267 /// ```
268 #[allow(deprecated)]
269 #[must_use]
270 pub fn from_args(args: &crate::cli::Args) -> Self {
271 let source = ConfigSource::from_args(args);
272 Self::from_source(&source)
273 }
274
275 /// Build configuration from parsed CLI arguments with a pre-loaded config file.
276 ///
277 /// This is the preferred constructor in production code because it:
278 /// 1. Eliminates double file loading (file config passed directly)
279 /// 2. Returns a `ValidationResult` for reporting warnings/errors
280 /// 3. Does NOT print or exit — callers control error handling
281 ///
282 /// For test code that doesn't need validation, use
283 /// [`from_source()`](Self::from_source) with a hand-built [`ConfigSource`].
284 ///
285 /// # Example
286 ///
287 /// ```ignore
288 /// use clap::Parser;
289 /// use netspeed_cli::cli::Args;
290 /// use netspeed_cli::config::{Config, ConfigSource, ValidationResult};
291 ///
292 /// // Parse CLI arguments and convert to ConfigSource
293 /// let args = Args::parse_from(["netspeed-cli", "--format", "json"]);
294 /// let source = ConfigSource::from_args(&args);
295 ///
296 /// // Load config file (or pass None for defaults)
297 /// let file_config = netspeed_cli::config::load_config_file();
298 ///
299 /// // Build config with validation results
300 /// let (config, validation) = Config::from_args_with_file(&source, file_config);
301 ///
302 /// // Handle validation results
303 /// for warning in &validation.warnings {
304 /// eprintln!("Warning: {warning}");
305 /// }
306 ///
307 /// // Continue with config...
308 /// assert_eq!(config.format(), Some(netspeed_cli::config::Format::Json));
309 /// ```
310 #[allow(deprecated)]
311 #[must_use]
312 pub fn from_args_with_file(
313 source: &ConfigSource,
314 file_config: Option<File>,
315 ) -> (Self, ValidationResult) {
316 let config = Self::from_source_with_file(source, file_config);
317
318 // Check profile validation (produces warning, not error)
319 let mut validation = ValidationResult::ok();
320 if let Some(ref profile_name) = source.output.profile {
321 if crate::profiles::UserProfile::validate(profile_name).is_err() {
322 validation = validation.with_warning(format!(
323 "Unknown profile '{}'. Valid options: {}. Using 'power-user'.",
324 profile_name,
325 crate::profiles::UserProfile::VALID_NAMES.join(", ")
326 ));
327 }
328 }
329
330 (config, validation)
331 }
332
333 /// Build configuration from a [`ConfigSource`] and the config file.
334 ///
335 /// This constructor operates entirely within the config layer — no
336 /// dependency on [`crate::cli::Args`]. Exposed as `pub` so that
337 /// external test crates can construct a [`Config`] from a hand-built
338 /// [`ConfigSource`] without going through CLI parsing.
339 ///
340 /// **Side-effect free**: This method does NOT print to stderr or exit.
341 /// Call [`Config::validate_and_report()`](Self::validate_and_report) separately
342 /// to emit validation warnings/errors, or use
343 /// [`Config::from_args_with_file()`](Self::from_args_with_file) which handles
344 /// validation automatically.
345 ///
346 /// # Merge Strategy
347 ///
348 /// Values are resolved with **CLI > file > hardcoded defaults** priority:
349 ///
350 /// - **`Option<bool>` fields** (e.g., `bytes`, `no_download`):
351 /// `cli.or(file).unwrap_or(false)` — CLI wins when `Some`, file is
352 /// the fallback, `false` when both are `None`.
353 ///
354 /// - **`Option<String>` fields** (e.g., `ca_cert`, `tls_version`):
355 /// `cli.or(file)` — CLI wins, file is the fallback, `None` when both
356 /// are absent.
357 ///
358 /// - **`u64` fields** (e.g., `timeout`):
359 /// If the CLI value equals the hardcoded default, the file value is
360 /// tried first; otherwise the CLI value wins. This lets `--timeout 10`
361 /// (the default) fall through to the file config while an explicit
362 /// `--timeout 30` always takes effect.
363 ///
364 /// - **`char` fields** (e.g., `csv_delimiter`):
365 /// If the CLI value equals the hardcoded default (`,`), the file value
366 /// is used; otherwise the CLI value wins.
367 ///
368 /// # Example
369 ///
370 /// External crates can build a [`Config`] without CLI parsing by
371 /// constructing a [`ConfigSource`] from its sub-source structs:
372 ///
373 /// ```no_run
374 /// use netspeed_cli::config::{
375 /// Config, ConfigSource, Format, NetworkSource, OutputSource,
376 /// };
377 ///
378 /// let source = ConfigSource {
379 /// output: OutputSource {
380 /// format: Some(Format::Json),
381 /// quiet: Some(true),
382 /// ..Default::default()
383 /// },
384 /// network: NetworkSource {
385 /// timeout: 30,
386 /// ..Default::default()
387 /// },
388 /// ..Default::default()
389 /// };
390 ///
391 /// let config = Config::from_source(&source);
392 /// assert_eq!(config.timeout(), 30);
393 /// assert_eq!(config.format(), Some(Format::Json));
394 /// ```
395 #[must_use]
396 pub fn from_source(source: &ConfigSource) -> Self {
397 let file_config = load_config_file().unwrap_or_default();
398 Self::from_source_with_file(source, Some(file_config))
399 }
400
401 /// Build configuration from a [`ConfigSource`] with a pre-loaded config file.
402 ///
403 /// This internal constructor accepts a pre-loaded file config to avoid
404 /// redundant file loading. Use this when you already have the file config
405 /// loaded (e.g., in `from_args_with_file()`).
406 ///
407 /// For test code without a pre-loaded config, use
408 /// [`from_source()`](Self::from_source) which loads the file internally.
409 #[must_use]
410 pub(crate) fn from_source_with_file(source: &ConfigSource, file_config: Option<File>) -> Self {
411 let file = file_config.unwrap_or_default();
412
413 let strict = source.strict_config.unwrap_or(file.strict.unwrap_or(false));
414
415 let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
416 let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
417 if cli == default {
418 file.unwrap_or(default)
419 } else {
420 cli
421 }
422 };
423
424 // Build sub-structs from sub-sources and file config
425 let output = OutputConfig::from_source(&source.output, &file, merge_bool);
426 let test = TestSelection::from_source(&source.test, &file, merge_bool);
427 let network = NetworkConfig::from_source(&source.network, &file, merge_bool, merge_u64);
428 let servers = ServerSelection::from_source(&source.servers);
429
430 Self {
431 output,
432 test,
433 network,
434 servers,
435 custom_user_agent: file.custom_user_agent.clone(),
436 strict,
437 }
438 }
439
440 /// Validate configuration and emit warnings/errors.
441 ///
442 /// This method handles the side effects that were removed from
443 /// [`from_source()`](Self::from_source): printing validation errors/warnings
444 /// and exiting in strict mode. Call this after building config from CLI args.
445 ///
446 /// Returns the [`ValidationResult`] so callers can decide how to handle
447 /// failures (e.g., exit in strict mode, log in normal mode).
448 ///
449 /// # Example
450 ///
451 /// ```no_run
452 /// use netspeed_cli::config::{Config, ConfigSource};
453 ///
454 /// let source = ConfigSource::default();
455 /// let config = Config::from_source(&source);
456 ///
457 /// // Emit validation warnings/errors
458 /// let result = config.validate_and_report(&source, None);
459 /// for error in &result.errors {
460 /// eprintln!("Error: {error}");
461 /// }
462 /// for warning in &result.warnings {
463 /// eprintln!("Warning: {warning}");
464 /// }
465 ///
466 /// // Exit if strict mode and validation failed
467 /// if config.strict() && !result.valid {
468 /// std::process::exit(1);
469 /// }
470 /// ```
471 #[must_use]
472 pub fn validate_and_report(
473 &self,
474 source: &ConfigSource,
475 file_config: Option<File>,
476 ) -> ValidationResult {
477 // Use pre-loaded file config if provided, otherwise load it
478 let file = file_config.unwrap_or_else(|| load_config_file().unwrap_or_default());
479
480 // Validate config file settings
481 let mut validation = validate_config(&file);
482
483 // Check profile validation (may differ from file config)
484 if let Some(ref profile_name) = source.output.profile {
485 if crate::profiles::UserProfile::validate(profile_name).is_err() {
486 validation = validation.with_warning(format!(
487 "Unknown profile '{}'. Valid options: {}. Using 'power-user'.",
488 profile_name,
489 crate::profiles::UserProfile::VALID_NAMES.join(", ")
490 ));
491 }
492 }
493
494 validation
495 }
496
497 /// Whether test results should be saved to history.
498 ///
499 /// Machine-readable formats (JSON, JSONL, CSV) corrupt stdout when
500 /// mixed with history output, so we skip saving in those cases.
501 ///
502 /// # Example
503 ///
504 /// ```
505 /// use netspeed_cli::config::Config;
506 ///
507 /// let config = Config::default();
508 /// // Default format is Detailed → should save history
509 /// assert!(config.should_save_history());
510 /// ```
511 #[must_use]
512 pub fn should_save_history(&self) -> bool {
513 // Machine-readable formats corrupt stdout
514 if self.format().is_some_and(|f| f.is_machine_readable()) {
515 return false;
516 }
517 // Legacy format flags also skip history
518 if self.json() || self.csv() {
519 return false;
520 }
521 true
522 }
523
524 // ========================================================================
525 // Output getters (delegates to output sub-struct)
526 // ========================================================================
527
528 // ========================================================================
529 // Test execution getters (delegates to test sub-struct)
530 // ========================================================================
531
532 /// Whether to skip download test.
533 #[must_use]
534 pub fn no_download(&self) -> bool {
535 self.test.no_download
536 }
537
538 /// Whether to skip upload test.
539 #[must_use]
540 pub fn no_upload(&self) -> bool {
541 self.test.no_upload
542 }
543
544 /// Whether to use single connection mode.
545 #[must_use]
546 pub fn single(&self) -> bool {
547 self.test.single
548 }
549
550 /// Whether to display values in bytes instead of bits.
551 #[must_use]
552 pub fn bytes(&self) -> bool {
553 self.output.bytes
554 }
555
556 /// Whether to use simple output format.
557 #[must_use]
558 pub fn simple(&self) -> bool {
559 self.output.simple
560 }
561
562 /// Whether to output in CSV format.
563 #[must_use]
564 pub fn csv(&self) -> bool {
565 self.output.csv
566 }
567
568 /// Whether to output in JSON format.
569 #[must_use]
570 pub fn json(&self) -> bool {
571 self.output.json
572 }
573
574 /// Whether to suppress all progress output.
575 #[must_use]
576 pub fn quiet(&self) -> bool {
577 self.output.quiet
578 }
579
580 /// Whether to display server list and exit.
581 #[must_use]
582 pub fn list(&self) -> bool {
583 self.output.list
584 }
585
586 /// Whether to use minimal ASCII-only output.
587 #[must_use]
588 pub fn minimal(&self) -> bool {
589 self.output.minimal
590 }
591
592 /// The color theme for terminal output.
593 #[must_use]
594 pub fn theme(&self) -> Theme {
595 self.output.theme
596 }
597
598 /// The CSV field delimiter.
599 #[must_use]
600 pub fn csv_delimiter(&self) -> char {
601 self.output.csv_delimiter
602 }
603
604 /// Whether to include CSV headers.
605 #[must_use]
606 pub fn csv_header(&self) -> bool {
607 self.output.csv_header
608 }
609
610 /// The user profile for customized output.
611 #[must_use]
612 pub fn profile(&self) -> Option<&str> {
613 self.output.profile.as_deref()
614 }
615
616 /// The output format (supersedes legacy --json/--csv/--simple).
617 #[must_use]
618 pub fn format(&self) -> Option<Format> {
619 self.output.format
620 }
621
622 // ========================================================================
623 // Network getters (delegates to network sub-struct)
624 // ========================================================================
625
626 /// The HTTP request timeout in seconds.
627 #[must_use]
628 pub fn timeout(&self) -> u64 {
629 self.network.timeout
630 }
631
632 /// The source IP address to bind to.
633 #[must_use]
634 pub fn source(&self) -> Option<&str> {
635 self.network.source.as_deref()
636 }
637
638 /// Path to custom CA certificate for TLS.
639 #[must_use]
640 pub fn ca_cert(&self) -> Option<&str> {
641 self.network.ca_cert.as_deref()
642 }
643
644 /// Path to custom CA certificate as [`PathBuf`] (avoids double-allocation).
645 ///
646 /// Internal-only: external consumers should use [`ca_cert()`] which returns `Option<&str>`.
647 #[must_use]
648 pub(crate) fn ca_cert_path(&self) -> Option<PathBuf> {
649 self.network.ca_cert.as_ref().map(PathBuf::from)
650 }
651
652 /// Minimum TLS version (1.2 or 1.3).
653 #[must_use]
654 pub fn tls_version(&self) -> Option<&str> {
655 self.network.tls_version.as_deref()
656 }
657
658 /// Whether speedtest.net/ookla.com TLS domain restriction is enabled.
659 #[must_use]
660 pub fn pin_certs(&self) -> bool {
661 self.network.pin_certs
662 }
663
664 // ========================================================================
665 // Server selection getters (delegates to servers sub-struct)
666 // ========================================================================
667
668 /// Specific server IDs to use (empty = auto-select).
669 #[must_use]
670 pub fn server_ids(&self) -> &[String] {
671 &self.servers.server_ids
672 }
673
674 /// Server IDs to exclude from selection.
675 #[must_use]
676 pub fn exclude_ids(&self) -> &[String] {
677 &self.servers.exclude_ids
678 }
679
680 // ========================================================================
681 // Top-level getters
682 // ========================================================================
683
684 /// Custom user agent string (file config only).
685 #[must_use]
686 pub fn custom_user_agent(&self) -> Option<&str> {
687 self.custom_user_agent.as_deref()
688 }
689
690 /// Whether strict validation mode is enabled.
691 #[must_use]
692 pub fn strict(&self) -> bool {
693 self.strict
694 }
695}
696
697#[cfg(test)]
698mod tests;