Skip to main content

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;