Skip to main content

netspeed_cli/config/
mod.rs

1use crate::theme::Theme;
2use directories::ProjectDirs;
3use serde::Deserialize;
4use std::fs;
5use std::path::PathBuf;
6
7pub mod output;
8
9// ============================================================================
10// ConfigSource — CLI→config bridge (DIP: config depends on abstraction)
11// ============================================================================
12
13/// Raw CLI input values for output settings.
14///
15/// Groups output-related fields from [`crate::cli::Args`] into a cohesive unit
16/// matching the structure of `OutputConfig`.
17///
18/// # Example
19///
20/// ```
21/// use netspeed_cli::config::{Format, OutputSource};
22///
23/// let src = OutputSource {
24///     format: Some(Format::Json),
25///     quiet: Some(true),
26///     ..Default::default()
27/// };
28///
29/// assert_eq!(src.format, Some(Format::Json));
30/// assert_eq!(src.csv_delimiter, ','); // business-logic default preserved
31/// assert_eq!(src.theme, "dark");       // business-logic default preserved
32/// ```
33#[derive(Debug, Clone)]
34pub struct OutputSource {
35    /// Display values in bytes instead of bits
36    pub bytes: Option<bool>,
37    /// Suppress verbose output (deprecated, use format)
38    pub simple: Option<bool>,
39    /// Output in CSV format (deprecated, use format)
40    pub csv: Option<bool>,
41    /// CSV field delimiter
42    pub csv_delimiter: char,
43    /// Include CSV headers
44    pub csv_header: Option<bool>,
45    /// Output in JSON format (deprecated, use format)
46    pub json: Option<bool>,
47    /// Display server list and exit
48    pub list: bool,
49    /// Suppress all progress output
50    pub quiet: Option<bool>,
51    /// Minimal ASCII-only output
52    pub minimal: Option<bool>,
53    /// User profile for customized output
54    pub profile: Option<String>,
55    /// Color theme name
56    pub theme: String,
57    /// Output format (supersedes legacy flags)
58    pub format: Option<Format>,
59}
60
61/// Raw CLI input values for test execution settings.
62///
63/// Groups test-selection fields from [`crate::cli::Args`] into a cohesive unit
64/// matching the structure of `TestSelection`.
65///
66/// # Example
67///
68/// ```
69/// use netspeed_cli::config::TestSource;
70///
71/// let src = TestSource {
72///     no_download: Some(true),
73///     single: Some(true),
74///     ..Default::default()
75/// };
76///
77/// assert_eq!(src.no_download, Some(true));
78/// assert!(src.no_upload.is_none()); // unset fields default to None
79/// ```
80#[derive(Debug, Clone, Default)]
81pub struct TestSource {
82    /// Do not perform download test
83    pub no_download: Option<bool>,
84    /// Do not perform upload test
85    pub no_upload: Option<bool>,
86    /// Use single connection instead of multiple
87    pub single: Option<bool>,
88}
89
90/// Raw CLI input values for network/transport settings.
91///
92/// Groups network-related fields from [`crate::cli::Args`] into a cohesive unit
93/// matching the structure of `NetworkConfig`.
94///
95/// # Example
96///
97/// ```
98/// use netspeed_cli::config::NetworkSource;
99///
100/// let src = NetworkSource {
101///     timeout: 30,
102///     tls_version: Some("1.3".to_string()),
103///     ..Default::default()
104/// };
105///
106/// assert_eq!(src.timeout, 30);
107/// assert!(src.source.is_none()); // unset fields default to None
108/// ```
109#[derive(Debug, Clone)]
110pub struct NetworkSource {
111    /// Source IP address to bind to
112    pub source: Option<String>,
113    /// HTTP request timeout in seconds
114    pub timeout: u64,
115    /// Path to custom CA certificate for TLS
116    pub ca_cert: Option<String>,
117    /// Minimum TLS version (1.2 or 1.3)
118    pub tls_version: Option<String>,
119    /// Enable certificate pinning for speedtest.net
120    pub pin_certs: Option<bool>,
121}
122
123/// Raw CLI input values for server selection settings.
124///
125/// Groups server-selection fields from [`crate::cli::Args`] into a cohesive unit
126/// matching the structure of `ServerSelection`.
127///
128/// # Example
129///
130/// ```
131/// use netspeed_cli::config::ServerSource;
132///
133/// let src = ServerSource {
134///     server_ids: vec!["1234".to_string()],
135///     ..Default::default()
136/// };
137///
138/// assert_eq!(src.server_ids, vec!["1234"]);
139/// assert!(src.exclude_ids.is_empty()); // unset fields default to empty
140/// ```
141#[derive(Debug, Clone, Default)]
142pub struct ServerSource {
143    /// Specific server IDs to use (empty = auto-select)
144    pub server_ids: Vec<String>,
145    /// Server IDs to exclude from selection
146    pub exclude_ids: Vec<String>,
147}
148
149/// Raw CLI input values extracted from parsed command-line arguments.
150///
151/// This struct is the sole bridge between the CLI layer ([`crate::cli::Args`])
152/// and the config layer. Sub-struct constructors (`from_source`) depend on
153/// the individual sub-source types instead of the concrete `Args` type,
154/// satisfying the Dependency Inversion Principle: high-level config modules
155/// depend on abstractions, not on low-level CLI parsing details.
156///
157/// All fields use config-internal types (e.g., [`Format`] instead of
158/// [`crate::cli::OutputFormatType`]). The conversion happens once at
159/// construction time via `ConfigSource::from_args`.
160///
161/// Composed of semantic sub-source structs matching the [`Config`] sub-struct
162/// pattern: [`OutputSource`], [`TestSource`], [`NetworkSource`], [`ServerSource`].
163///
164/// # Example
165///
166/// Build a [`Config`] without CLI parsing by constructing sub-sources:
167///
168/// ```no_run
169/// use netspeed_cli::config::{
170///     Config, ConfigSource, Format, NetworkSource, OutputSource, TestSource,
171/// };
172///
173/// let source = ConfigSource {
174///     output: OutputSource {
175///         format: Some(Format::Dashboard),
176///         profile: Some("gamer".to_string()),
177///         ..Default::default()
178///     },
179///     test: TestSource {
180///         no_upload: Some(true),
181///         ..Default::default()
182///     },
183///     network: NetworkSource {
184///         timeout: 60,
185///         ..Default::default()
186///     },
187///     ..Default::default()
188/// };
189///
190/// let config = Config::from_source(&source);
191/// assert_eq!(config.timeout(), 60);
192/// assert!(config.no_upload());
193/// ```
194#[derive(Debug, Clone, Default)]
195pub struct ConfigSource {
196    /// Output and display settings
197    pub output: OutputSource,
198    /// Test execution controls
199    pub test: TestSource,
200    /// Network and transport settings
201    pub network: NetworkSource,
202    /// Server selection criteria
203    pub servers: ServerSource,
204    /// Enable strict config validation mode
205    pub strict_config: Option<bool>,
206}
207
208// OutputSource and NetworkSource have manual Default impls for business logic defaults
209// (csv_delimiter: ',', theme: "dark" and timeout: 10 respectively)
210
211impl Default for OutputSource {
212    fn default() -> Self {
213        Self {
214            bytes: None,
215            simple: None,
216            csv: None,
217            csv_delimiter: ',',
218            csv_header: None,
219            json: None,
220            list: false,
221            quiet: None,
222            minimal: None,
223            profile: None,
224            theme: "dark".to_string(),
225            format: None,
226        }
227    }
228}
229
230impl Default for NetworkSource {
231    fn default() -> Self {
232        Self {
233            source: None,
234            timeout: 10,
235            ca_cert: None,
236            tls_version: None,
237            pin_certs: None,
238        }
239    }
240}
241
242impl ConfigSource {
243    /// Extract config-relevant values from parsed CLI arguments.
244    ///
245    /// This is the **only** method in the config layer that touches
246    /// [`crate::cli::Args`]. All downstream code uses [`ConfigSource`].
247    ///
248    /// For tests that don't need CLI parsing, construct a [`ConfigSource`]
249    /// directly or use [`ConfigSource::default()`].
250    #[must_use]
251    #[allow(deprecated)] // accesses deprecated --simple/--csv/--json fields for backward compat
252    pub(crate) fn from_args(args: &crate::cli::Args) -> Self {
253        Self {
254            output: OutputSource {
255                bytes: args.bytes,
256                simple: args.simple,
257                csv: args.csv,
258                csv_delimiter: args.csv_delimiter,
259                csv_header: args.csv_header,
260                json: args.json,
261                list: args.list,
262                quiet: args.quiet,
263                minimal: args.minimal,
264                profile: args.profile.clone(),
265                theme: args.theme.clone(),
266                format: args.format.map(Format::from_cli_type),
267            },
268            test: TestSource {
269                no_download: args.no_download,
270                no_upload: args.no_upload,
271                single: args.single,
272            },
273            network: NetworkSource {
274                source: args.source.clone(),
275                timeout: args.timeout,
276                ca_cert: args.ca_cert.clone(),
277                tls_version: args.tls_version.clone(),
278                pin_certs: args.pin_certs,
279            },
280            servers: ServerSource {
281                server_ids: args.server.clone(),
282                exclude_ids: args.exclude.clone(),
283            },
284            strict_config: args.strict_config,
285        }
286    }
287}
288
289// ============================================================================
290// Semantic config sub-structs (SRP: each struct has single responsibility)
291// ============================================================================
292
293/// Output format selection — config-internal domain type.
294///
295/// Decoupled from [`crate::cli::OutputFormatType`] (which carries clap's `ValueEnum`
296/// derive). The CLI enum is converted into this type at the config boundary via
297/// `Format::from_cli_type`, so the config layer never depends on the CLI crate.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum Format {
300    /// Machine-readable JSON output
301    Json,
302    /// JSON Lines for logging (one JSON object per line)
303    Jsonl,
304    /// CSV format for spreadsheet analysis
305    Csv,
306    /// Ultra-minimal: just grade + speeds
307    Minimal,
308    /// Minimal one-line summary
309    Simple,
310    /// Key metrics with quality ratings
311    Compact,
312    /// Full analysis with per-metric grades (default)
313    Detailed,
314    /// Rich terminal dashboard with capability matrix
315    Dashboard,
316}
317
318impl Format {
319    /// Convert from the CLI-specific [`crate::cli::OutputFormatType`] enum.
320    ///
321    /// This is the only place the config layer touches the CLI type —
322    /// all downstream consumers use [`Format`] instead.
323    ///
324    /// # Example
325    ///
326    /// ```ignore
327    /// use netspeed_cli::config::Format;
328    ///
329    /// // Convert CLI enum to config-internal enum
330    /// let fmt = Format::from_cli_type(netspeed_cli::cli::OutputFormatType::Json);
331    /// assert_eq!(fmt, Format::Json);
332    ///
333    /// let fmt = Format::from_cli_type(netspeed_cli::cli::OutputFormatType::Dashboard);
334    /// assert_eq!(fmt, Format::Dashboard);
335    /// ```
336    #[must_use]
337    pub(crate) fn from_cli_type(cli: crate::cli::OutputFormatType) -> Self {
338        match cli {
339            crate::cli::OutputFormatType::Json => Self::Json,
340            crate::cli::OutputFormatType::Jsonl => Self::Jsonl,
341            crate::cli::OutputFormatType::Csv => Self::Csv,
342            crate::cli::OutputFormatType::Minimal => Self::Minimal,
343            crate::cli::OutputFormatType::Simple => Self::Simple,
344            crate::cli::OutputFormatType::Compact => Self::Compact,
345            crate::cli::OutputFormatType::Detailed => Self::Detailed,
346            crate::cli::OutputFormatType::Dashboard => Self::Dashboard,
347        }
348    }
349
350    /// Whether this format is machine-readable (JSON/JSONL/CSV).
351    ///
352    /// # Example
353    ///
354    /// ```
355    /// use netspeed_cli::config::Format;
356    ///
357    /// // JSON, JSONL, and CSV are machine-readable
358    /// assert!(Format::Json.is_machine_readable());
359    /// assert!(Format::Jsonl.is_machine_readable());
360    /// assert!(Format::Csv.is_machine_readable());
361    ///
362    /// // All other formats are human-readable only
363    /// for fmt in [Format::Minimal, Format::Simple, Format::Compact,
364    ///             Format::Detailed, Format::Dashboard] {
365    ///     assert!(!fmt.is_machine_readable());
366    /// }
367    /// ```
368    #[must_use]
369    pub fn is_machine_readable(self) -> bool {
370        matches!(self, Self::Json | Self::Jsonl | Self::Csv)
371    }
372
373    /// Whether this format produces non-verbose (terse) output.
374    ///
375    /// All formats except [`Detailed`](Format::Detailed) are considered non-verbose.
376    ///
377    /// # Example
378    ///
379    /// ```
380    /// use netspeed_cli::config::Format;
381    ///
382    /// // Detailed is the only verbose format
383    /// assert!(!Format::Detailed.is_non_verbose());
384    ///
385    /// // Everything else is non-verbose (terse)
386    /// for fmt in [Format::Simple, Format::Minimal, Format::Compact,
387    ///             Format::Json, Format::Jsonl, Format::Csv,
388    ///             Format::Dashboard] {
389    ///     assert!(fmt.is_non_verbose());
390    /// }
391    /// ```
392    #[must_use]
393    pub fn is_non_verbose(self) -> bool {
394        matches!(
395            self,
396            Self::Simple
397                | Self::Minimal
398                | Self::Compact
399                | Self::Json
400                | Self::Jsonl
401                | Self::Csv
402                | Self::Dashboard
403        )
404    }
405
406    /// Human-readable label for display (e.g., dry-run output).
407    ///
408    /// # Example
409    ///
410    /// ```
411    /// use netspeed_cli::config::Format;
412    ///
413    /// assert_eq!(Format::Json.label(), "JSON");
414    /// assert_eq!(Format::Dashboard.label(), "Dashboard");
415    /// assert_eq!(Format::Compact.label(), "Compact");
416    ///
417    /// // Labels are also used via Display trait
418    /// let csv = Format::Csv;
419    /// assert_eq!(format!("{csv}"), "CSV");
420    /// ```
421    #[must_use]
422    pub fn label(self) -> &'static str {
423        match self {
424            Self::Json => "JSON",
425            Self::Jsonl => "JSONL",
426            Self::Csv => "CSV",
427            Self::Minimal => "Minimal",
428            Self::Simple => "Simple",
429            Self::Compact => "Compact",
430            Self::Detailed => "Detailed",
431            Self::Dashboard => "Dashboard",
432        }
433    }
434}
435
436impl std::fmt::Display for Format {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        f.write_str(self.label())
439    }
440}
441
442/// Output and display configuration.
443/// Controls how test results are formatted and presented to the user.
444///
445/// # Example
446///
447/// ```ignore
448/// use netspeed_cli::config::{Format, OutputConfig, OutputSource, File};
449///
450/// let source = OutputSource {
451///     format: Some(Format::Json),
452///     quiet: Some(true),
453///     ..Default::default()
454/// };
455/// let file_config = File::default();
456/// let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
457///
458/// let output = OutputConfig::from_source(&source, &file_config, merge_bool);
459/// assert!(output.quiet);
460/// assert_eq!(output.csv_delimiter, ','); // business-logic default preserved
461/// ```
462#[derive(Debug, Clone)]
463pub struct OutputConfig {
464    /// Display values in bytes instead of bits
465    pub bytes: bool,
466    /// Suppress verbose output (deprecated, use format)
467    pub simple: bool,
468    /// Output in CSV format (deprecated, use format)
469    pub csv: bool,
470    /// CSV field delimiter
471    pub csv_delimiter: char,
472    /// Include CSV headers
473    pub csv_header: bool,
474    /// Output in JSON format (deprecated, use format)
475    pub json: bool,
476    /// Display server list and exit
477    pub list: bool,
478    /// Suppress all progress output
479    pub quiet: bool,
480    /// User profile for customized output
481    pub profile: Option<String>,
482    /// Color theme for terminal output
483    pub theme: Theme,
484    /// Minimal ASCII-only output (no Unicode box-drawing)
485    pub minimal: bool,
486    /// Output format (supersedes legacy --json/--csv/--simple)
487    pub format: Option<Format>,
488}
489
490// OutputConfig and NetworkConfig have manual Default impls for business logic defaults
491// (csv_delimiter: ',' and timeout: 10 respectively)
492
493impl Default for OutputConfig {
494    fn default() -> Self {
495        Self {
496            bytes: false,
497            simple: false,
498            csv: false,
499            csv_delimiter: ',',
500            csv_header: false,
501            json: false,
502            list: false,
503            quiet: false,
504            profile: None,
505            theme: Theme::Dark,
506            minimal: false,
507            format: None,
508        }
509    }
510}
511
512impl OutputConfig {
513    /// Convert to merged output config from CLI source and file config.
514    #[must_use]
515    #[allow(deprecated)]
516    pub(crate) fn from_source(
517        source: &OutputSource,
518        file_config: &File,
519        merge_bool: impl Fn(Option<bool>, Option<bool>) -> bool,
520    ) -> Self {
521        let theme = if source.theme == "dark" {
522            file_config
523                .theme
524                .as_ref()
525                .and_then(|t| Theme::from_name(t))
526                .unwrap_or_default()
527        } else {
528            Theme::from_name(&source.theme).unwrap_or_default()
529        };
530
531        Self {
532            bytes: merge_bool(source.bytes, file_config.bytes),
533            simple: merge_bool(source.simple, file_config.simple),
534            csv: merge_bool(source.csv, file_config.csv),
535            csv_delimiter: if source.csv_delimiter == ',' {
536                file_config.csv_delimiter.unwrap_or(',')
537            } else {
538                source.csv_delimiter
539            },
540            csv_header: merge_bool(source.csv_header, file_config.csv_header),
541            json: merge_bool(source.json, file_config.json),
542            list: source.list,
543            quiet: merge_bool(source.quiet, None),
544            profile: source.profile.clone().or(file_config.profile.clone()),
545            theme,
546            minimal: merge_bool(source.minimal, None),
547            format: source.format,
548        }
549    }
550}
551
552/// Test execution configuration.
553/// Controls which tests run and how (single vs multi-stream).
554///
555/// Named `TestSelection` to avoid collision with [`crate::test_config::TestConfig`]
556/// which controls bandwidth measurement parameters (rounds, streams, payloads).
557///
558/// # Example
559///
560/// ```ignore
561/// use netspeed_cli::config::{TestSelection, TestSource, File};
562///
563/// let source = TestSource {
564///     no_download: Some(true),
565///     single: Some(true),
566///     ..Default::default()
567/// };
568/// let file_config = File::default();
569/// let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
570///
571/// let test = TestSelection::from_source(&source, &file_config, merge_bool);
572/// assert!(test.no_download);
573/// assert!(test.single);
574/// assert!(!test.no_upload); // unset → false default
575/// ```
576#[derive(Debug, Clone, Default)]
577pub struct TestSelection {
578    /// Do not perform download test
579    pub no_download: bool,
580    /// Do not perform upload test
581    pub no_upload: bool,
582    /// Use single connection instead of multiple
583    pub single: bool,
584}
585
586impl TestSelection {
587    /// Convert to merged test selection from CLI source and file config.
588    #[must_use]
589    pub(crate) fn from_source(
590        source: &TestSource,
591        file_config: &File,
592        merge_bool: impl Fn(Option<bool>, Option<bool>) -> bool,
593    ) -> Self {
594        Self {
595            no_download: merge_bool(source.no_download, file_config.no_download),
596            no_upload: merge_bool(source.no_upload, file_config.no_upload),
597            single: merge_bool(source.single, file_config.single),
598        }
599    }
600}
601
602/// Network and transport configuration.
603/// Controls connection behavior, timeouts, and TLS settings.
604///
605/// # Example
606///
607/// ```ignore
608/// use netspeed_cli::config::{NetworkConfig, NetworkSource, File};
609///
610/// let source = NetworkSource {
611///     timeout: 30,
612///     tls_version: Some("1.3".to_string()),
613///     pin_certs: Some(true),
614///     ..Default::default()
615/// };
616/// let file_config = File::default();
617/// let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
618/// let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
619///     if cli == default { file.unwrap_or(default) } else { cli }
620/// };
621///
622/// let network = NetworkConfig::from_source(&source, &file_config, merge_bool, merge_u64);
623/// assert_eq!(network.timeout, 30);
624/// assert_eq!(network.tls_version, Some("1.3".to_string()));
625/// assert!(network.pin_certs);
626/// assert!(network.source.is_none()); // unset → None default
627/// ```
628#[derive(Debug, Clone)]
629pub struct NetworkConfig {
630    /// Source IP address to bind to
631    pub source: Option<String>,
632    /// HTTP request timeout in seconds
633    pub timeout: u64,
634    /// Path to custom CA certificate for TLS
635    pub ca_cert: Option<String>,
636    /// Minimum TLS version (1.2 or 1.3)
637    pub tls_version: Option<String>,
638    /// Enable certificate pinning for speedtest.net
639    pub pin_certs: bool,
640}
641
642impl Default for NetworkConfig {
643    fn default() -> Self {
644        Self {
645            source: None,
646            timeout: 10,
647            ca_cert: None,
648            tls_version: None,
649            pin_certs: false,
650        }
651    }
652}
653
654impl NetworkConfig {
655    /// Convert from CLI source and file config to merged network config.
656    #[must_use]
657    pub(crate) fn from_source(
658        source: &NetworkSource,
659        file_config: &File,
660        merge_bool: impl Fn(Option<bool>, Option<bool>) -> bool,
661        merge_u64: impl Fn(u64, Option<u64>, u64) -> u64,
662    ) -> Self {
663        Self {
664            source: source.source.clone(),
665            timeout: merge_u64(source.timeout, file_config.timeout, 10),
666            ca_cert: source.ca_cert.clone().or(file_config.ca_cert.clone()),
667            tls_version: source
668                .tls_version
669                .clone()
670                .or(file_config.tls_version.clone()),
671            pin_certs: merge_bool(source.pin_certs, file_config.pin_certs),
672        }
673    }
674}
675
676/// Server selection configuration.
677/// Controls which speedtest.net servers are used.
678///
679/// # Example
680///
681/// ```ignore
682/// use netspeed_cli::config::{ServerSelection, ServerSource};
683///
684/// let source = ServerSource {
685///     server_ids: vec!["1234".to_string(), "5678".to_string()],
686///     exclude_ids: vec!["9999".to_string()],
687/// };
688///
689/// let servers = ServerSelection::from_source(&source);
690/// assert_eq!(servers.server_ids, vec!["1234", "5678"]);
691/// assert_eq!(servers.exclude_ids, vec!["9999"]);
692/// ```
693#[derive(Debug, Clone, Default)]
694pub struct ServerSelection {
695    /// Specific server IDs to use (empty = auto-select)
696    pub server_ids: Vec<String>,
697    /// Server IDs to exclude from selection
698    pub exclude_ids: Vec<String>,
699}
700
701impl ServerSelection {
702    /// Create from CLI source.
703    #[must_use]
704    pub(crate) fn from_source(source: &ServerSource) -> Self {
705        Self {
706            server_ids: source.server_ids.clone(),
707            exclude_ids: source.exclude_ids.clone(),
708        }
709    }
710}
711
712// ============================================================================
713// Main Config struct (composition of sub-structs)
714// ============================================================================
715
716#[derive(Debug, Default, Clone, Deserialize)]
717pub struct File {
718    pub no_download: Option<bool>,
719    pub no_upload: Option<bool>,
720    pub single: Option<bool>,
721    pub bytes: Option<bool>,
722    pub simple: Option<bool>,
723    pub csv: Option<bool>,
724    pub csv_delimiter: Option<char>,
725    pub csv_header: Option<bool>,
726    pub json: Option<bool>,
727    pub timeout: Option<u64>,
728    pub profile: Option<String>,
729    pub theme: Option<String>,
730    /// Custom user agent string (optional, defaults to browser-like UA).
731    pub custom_user_agent: Option<String>,
732    /// Enable strict config mode - invalid values cause warnings.
733    pub strict: Option<bool>,
734    /// Path to a custom CA certificate file for TLS verification.
735    pub ca_cert: Option<String>,
736    /// Minimum TLS version (1.2 or 1.3).
737    pub tls_version: Option<String>,
738    /// Enable certificate pinning for speedtest.net servers.
739    pub pin_certs: Option<bool>,
740}
741
742/// Main configuration struct composed of semantic sub-structs.
743///
744/// Groups related configuration into cohesive units for better code organization:
745/// - `OutputConfig` — output and display settings
746/// - `TestSelection` — test execution controls
747/// - `NetworkConfig` — network and TLS settings
748/// - `ServerSelection` — server filtering options
749
750#[derive(Debug, Clone, Default)]
751pub struct Config {
752    /// Output and display configuration
753    pub output: OutputConfig,
754    /// Test execution controls
755    pub test: TestSelection,
756    /// Network and transport configuration
757    pub network: NetworkConfig,
758    /// Server selection criteria
759    pub servers: ServerSelection,
760    /// Custom user agent (file config only, not CLI)
761    pub custom_user_agent: Option<String>,
762    /// Strict validation mode
763    pub strict: bool,
764}
765
766// ConfigProvider trait exposing read‑only config
767pub trait ConfigProvider: Send + Sync {
768    fn config(&self) -> &Config;
769}
770
771impl ConfigProvider for Config {
772    fn config(&self) -> &Config {
773        self
774    }
775}
776
777impl Config {
778    /// Build configuration from parsed CLI arguments and the config file.
779    ///
780    /// Converts `Args` into [`ConfigSource`] first (the sole CLI→config bridge),
781    /// then builds sub-structs from the source abstraction.
782    ///
783    /// **Note:** This method does NOT call [`validate_and_report()`](Self::validate_and_report).
784    /// For validation with warnings/errors, use
785    /// [`from_args_with_file()`](Self::from_args_with_file) instead, which also
786    /// avoids loading the config file twice.
787    ///
788    /// # Example
789    ///
790    /// ```ignore
791    /// use clap::Parser;
792    /// use netspeed_cli::cli::Args;
793    /// use netspeed_cli::config::{Config, Format};
794    ///
795    /// // Parse CLI arguments (same as the main entry point)
796    /// let args = Args::parse_from(["netspeed-cli", "--format", "json", "--timeout", "30"]);
797    ///
798    /// let config = Config::from_args(&args);
799    /// assert_eq!(config.timeout(), 30);
800    /// assert_eq!(config.format(), Some(Format::Json));
801    /// assert!(!config.no_download()); // unset flags default to false
802    /// ```
803    #[allow(deprecated)]
804    #[must_use]
805    pub fn from_args(args: &crate::cli::Args) -> Self {
806        let source = ConfigSource::from_args(args);
807        Self::from_source(&source)
808    }
809
810    /// Build configuration from parsed CLI arguments with a pre-loaded config file.
811    ///
812    /// This is the preferred constructor in production code because it:
813    /// 1. Eliminates double file loading (file config passed directly)
814    /// 2. Returns a `ValidationResult` for reporting warnings/errors
815    /// 3. Does NOT print or exit — callers control error handling
816    ///
817    /// For test code that doesn't need validation, use
818    /// [`from_source()`](Self::from_source) with a hand-built [`ConfigSource`].
819    ///
820    /// # Example
821    ///
822    /// ```ignore
823    /// use clap::Parser;
824    /// use netspeed_cli::cli::Args;
825    /// use netspeed_cli::config::{Config, ConfigSource, ValidationResult};
826    ///
827    /// // Parse CLI arguments and convert to ConfigSource
828    /// let args = Args::parse_from(["netspeed-cli", "--format", "json"]);
829    /// let source = ConfigSource::from_args(&args);
830    ///
831    /// // Load config file (or pass None for defaults)
832    /// let file_config = netspeed_cli::config::load_config_file();
833    ///
834    /// // Build config with validation results
835    /// let (config, validation) = Config::from_args_with_file(&source, file_config);
836    ///
837    /// // Handle validation results
838    /// for warning in &validation.warnings {
839    ///     eprintln!("Warning: {warning}");
840    /// }
841    ///
842    /// // Continue with config...
843    /// assert_eq!(config.format(), Some(netspeed_cli::config::Format::Json));
844    /// ```
845    #[allow(deprecated)]
846    #[must_use]
847    pub fn from_args_with_file(
848        source: &ConfigSource,
849        file_config: Option<File>,
850    ) -> (Self, ValidationResult) {
851        let config = Self::from_source_with_file(source, file_config);
852
853        // Check profile validation (produces warning, not error)
854        let mut validation = ValidationResult::ok();
855        if let Some(ref profile_name) = source.output.profile {
856            if crate::profiles::UserProfile::validate(profile_name).is_err() {
857                validation = validation.with_warning(format!(
858                    "Unknown profile '{}'. Valid options: {}. Using 'power-user'.",
859                    profile_name,
860                    crate::profiles::UserProfile::VALID_NAMES.join(", ")
861                ));
862            }
863        }
864
865        (config, validation)
866    }
867
868    /// Build configuration from a [`ConfigSource`] and the config file.
869    ///
870    /// This constructor operates entirely within the config layer — no
871    /// dependency on [`crate::cli::Args`]. Exposed as `pub` so that
872    /// external test crates can construct a [`Config`] from a hand-built
873    /// [`ConfigSource`] without going through CLI parsing.
874    ///
875    /// **Side-effect free**: This method does NOT print to stderr or exit.
876    /// Call [`Config::validate_and_report()`](Self::validate_and_report) separately
877    /// to emit validation warnings/errors, or use
878    /// [`Config::from_args_with_file()`](Self::from_args_with_file) which handles
879    /// validation automatically.
880    ///
881    /// # Merge Strategy
882    ///
883    /// Values are resolved with **CLI > file > hardcoded defaults** priority:
884    ///
885    /// - **`Option<bool>` fields** (e.g., `bytes`, `no_download`):
886    ///   `cli.or(file).unwrap_or(false)` — CLI wins when `Some`, file is
887    ///   the fallback, `false` when both are `None`.
888    ///
889    /// - **`Option<String>` fields** (e.g., `ca_cert`, `tls_version`):
890    ///   `cli.or(file)` — CLI wins, file is the fallback, `None` when both
891    ///   are absent.
892    ///
893    /// - **`u64` fields** (e.g., `timeout`):
894    ///   If the CLI value equals the hardcoded default, the file value is
895    ///   tried first; otherwise the CLI value wins. This lets `--timeout 10`
896    ///   (the default) fall through to the file config while an explicit
897    ///   `--timeout 30` always takes effect.
898    ///
899    /// - **`char` fields** (e.g., `csv_delimiter`):
900    ///   If the CLI value equals the hardcoded default (`,`), the file value
901    ///   is used; otherwise the CLI value wins.
902    ///
903    /// # Example
904    ///
905    /// External crates can build a [`Config`] without CLI parsing by
906    /// constructing a [`ConfigSource`] from its sub-source structs:
907    ///
908    /// ```no_run
909    /// use netspeed_cli::config::{
910    ///     Config, ConfigSource, Format, NetworkSource, OutputSource,
911    /// };
912    ///
913    /// let source = ConfigSource {
914    ///     output: OutputSource {
915    ///         format: Some(Format::Json),
916    ///         quiet: Some(true),
917    ///         ..Default::default()
918    ///     },
919    ///     network: NetworkSource {
920    ///         timeout: 30,
921    ///         ..Default::default()
922    ///     },
923    ///     ..Default::default()
924    /// };
925    ///
926    /// let config = Config::from_source(&source);
927    /// assert_eq!(config.timeout(), 30);
928    /// assert_eq!(config.format(), Some(Format::Json));
929    /// ```
930    #[must_use]
931    pub fn from_source(source: &ConfigSource) -> Self {
932        let file_config = load_config_file().unwrap_or_default();
933        Self::from_source_with_file(source, Some(file_config))
934    }
935
936    /// Build configuration from a [`ConfigSource`] with a pre-loaded config file.
937    ///
938    /// This internal constructor accepts a pre-loaded file config to avoid
939    /// redundant file loading. Use this when you already have the file config
940    /// loaded (e.g., in `from_args_with_file()`).
941    ///
942    /// For test code without a pre-loaded config, use
943    /// [`from_source()`](Self::from_source) which loads the file internally.
944    #[must_use]
945    pub(crate) fn from_source_with_file(source: &ConfigSource, file_config: Option<File>) -> Self {
946        let file = file_config.unwrap_or_default();
947
948        let strict = source.strict_config.unwrap_or(file.strict.unwrap_or(false));
949
950        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
951        let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
952            if cli == default {
953                file.unwrap_or(default)
954            } else {
955                cli
956            }
957        };
958
959        // Build sub-structs from sub-sources and file config
960        let output = OutputConfig::from_source(&source.output, &file, merge_bool);
961        let test = TestSelection::from_source(&source.test, &file, merge_bool);
962        let network = NetworkConfig::from_source(&source.network, &file, merge_bool, merge_u64);
963        let servers = ServerSelection::from_source(&source.servers);
964
965        Self {
966            output,
967            test,
968            network,
969            servers,
970            custom_user_agent: file.custom_user_agent.clone(),
971            strict,
972        }
973    }
974
975    /// Validate configuration and emit warnings/errors.
976    ///
977    /// This method handles the side effects that were removed from
978    /// [`from_source()`](Self::from_source): printing validation errors/warnings
979    /// and exiting in strict mode. Call this after building config from CLI args.
980    ///
981    /// Returns the [`ValidationResult`] so callers can decide how to handle
982    /// failures (e.g., exit in strict mode, log in normal mode).
983    ///
984    /// # Example
985    ///
986    /// ```no_run
987    /// use netspeed_cli::config::{Config, ConfigSource};
988    ///
989    /// let source = ConfigSource::default();
990    /// let config = Config::from_source(&source);
991    ///
992    /// // Emit validation warnings/errors
993    /// let result = config.validate_and_report(&source, None);
994    /// for error in &result.errors {
995    ///     eprintln!("Error: {error}");
996    /// }
997    /// for warning in &result.warnings {
998    ///     eprintln!("Warning: {warning}");
999    /// }
1000    ///
1001    /// // Exit if strict mode and validation failed
1002    /// if config.strict() && !result.valid {
1003    ///     std::process::exit(1);
1004    /// }
1005    /// ```
1006    #[must_use]
1007    pub fn validate_and_report(
1008        &self,
1009        source: &ConfigSource,
1010        file_config: Option<File>,
1011    ) -> ValidationResult {
1012        // Use pre-loaded file config if provided, otherwise load it
1013        let file = file_config.unwrap_or_else(|| load_config_file().unwrap_or_default());
1014
1015        // Validate config file settings
1016        let mut validation = validate_config(&file);
1017
1018        // Check profile validation (may differ from file config)
1019        if let Some(ref profile_name) = source.output.profile {
1020            if crate::profiles::UserProfile::validate(profile_name).is_err() {
1021                validation = validation.with_warning(format!(
1022                    "Unknown profile '{}'. Valid options: {}. Using 'power-user'.",
1023                    profile_name,
1024                    crate::profiles::UserProfile::VALID_NAMES.join(", ")
1025                ));
1026            }
1027        }
1028
1029        validation
1030    }
1031
1032    /// Whether test results should be saved to history.
1033    ///
1034    /// Machine-readable formats (JSON, JSONL, CSV) corrupt stdout when
1035    /// mixed with history output, so we skip saving in those cases.
1036    ///
1037    /// # Example
1038    ///
1039    /// ```
1040    /// use netspeed_cli::config::Config;
1041    ///
1042    /// let config = Config::default();
1043    /// // Default format is Detailed → should save history
1044    /// assert!(config.should_save_history());
1045    /// ```
1046    #[must_use]
1047    pub fn should_save_history(&self) -> bool {
1048        // Machine-readable formats corrupt stdout
1049        if self.format().is_some_and(|f| f.is_machine_readable()) {
1050            return false;
1051        }
1052        // Legacy format flags also skip history
1053        if self.json() || self.csv() {
1054            return false;
1055        }
1056        true
1057    }
1058
1059    // ========================================================================
1060    // Output getters (delegates to output sub-struct)
1061    // ========================================================================
1062
1063    // ========================================================================
1064    // Test execution getters (delegates to test sub-struct)
1065    // ========================================================================
1066
1067    /// Whether to skip download test.
1068    #[must_use]
1069    pub fn no_download(&self) -> bool {
1070        self.test.no_download
1071    }
1072
1073    /// Whether to skip upload test.
1074    #[must_use]
1075    pub fn no_upload(&self) -> bool {
1076        self.test.no_upload
1077    }
1078
1079    /// Whether to use single connection mode.
1080    #[must_use]
1081    pub fn single(&self) -> bool {
1082        self.test.single
1083    }
1084
1085    /// Whether to display values in bytes instead of bits.
1086    #[must_use]
1087    pub fn bytes(&self) -> bool {
1088        self.output.bytes
1089    }
1090
1091    /// Whether to use simple output format.
1092    #[must_use]
1093    pub fn simple(&self) -> bool {
1094        self.output.simple
1095    }
1096
1097    /// Whether to output in CSV format.
1098    #[must_use]
1099    pub fn csv(&self) -> bool {
1100        self.output.csv
1101    }
1102
1103    /// Whether to output in JSON format.
1104    #[must_use]
1105    pub fn json(&self) -> bool {
1106        self.output.json
1107    }
1108
1109    /// Whether to suppress all progress output.
1110    #[must_use]
1111    pub fn quiet(&self) -> bool {
1112        self.output.quiet
1113    }
1114
1115    /// Whether to display server list and exit.
1116    #[must_use]
1117    pub fn list(&self) -> bool {
1118        self.output.list
1119    }
1120
1121    /// Whether to use minimal ASCII-only output.
1122    #[must_use]
1123    pub fn minimal(&self) -> bool {
1124        self.output.minimal
1125    }
1126
1127    /// The color theme for terminal output.
1128    #[must_use]
1129    pub fn theme(&self) -> Theme {
1130        self.output.theme
1131    }
1132
1133    /// The CSV field delimiter.
1134    #[must_use]
1135    pub fn csv_delimiter(&self) -> char {
1136        self.output.csv_delimiter
1137    }
1138
1139    /// Whether to include CSV headers.
1140    #[must_use]
1141    pub fn csv_header(&self) -> bool {
1142        self.output.csv_header
1143    }
1144
1145    /// The user profile for customized output.
1146    #[must_use]
1147    pub fn profile(&self) -> Option<&str> {
1148        self.output.profile.as_deref()
1149    }
1150
1151    /// The output format (supersedes legacy --json/--csv/--simple).
1152    #[must_use]
1153    pub fn format(&self) -> Option<Format> {
1154        self.output.format
1155    }
1156
1157    // ========================================================================
1158    // Network getters (delegates to network sub-struct)
1159    // ========================================================================
1160
1161    /// The HTTP request timeout in seconds.
1162    #[must_use]
1163    pub fn timeout(&self) -> u64 {
1164        self.network.timeout
1165    }
1166
1167    /// The source IP address to bind to.
1168    #[must_use]
1169    pub fn source(&self) -> Option<&str> {
1170        self.network.source.as_deref()
1171    }
1172
1173    /// Path to custom CA certificate for TLS.
1174    #[must_use]
1175    pub fn ca_cert(&self) -> Option<&str> {
1176        self.network.ca_cert.as_deref()
1177    }
1178
1179    /// Path to custom CA certificate as [`PathBuf`] (avoids double-allocation).
1180    ///
1181    /// Internal-only: external consumers should use [`ca_cert()`] which returns `Option<&str>`.
1182    #[must_use]
1183    pub(crate) fn ca_cert_path(&self) -> Option<PathBuf> {
1184        self.network.ca_cert.as_ref().map(PathBuf::from)
1185    }
1186
1187    /// Minimum TLS version (1.2 or 1.3).
1188    #[must_use]
1189    pub fn tls_version(&self) -> Option<&str> {
1190        self.network.tls_version.as_deref()
1191    }
1192
1193    /// Whether certificate pinning is enabled.
1194    #[must_use]
1195    pub fn pin_certs(&self) -> bool {
1196        self.network.pin_certs
1197    }
1198
1199    // ========================================================================
1200    // Server selection getters (delegates to servers sub-struct)
1201    // ========================================================================
1202
1203    /// Specific server IDs to use (empty = auto-select).
1204    #[must_use]
1205    pub fn server_ids(&self) -> &[String] {
1206        &self.servers.server_ids
1207    }
1208
1209    /// Server IDs to exclude from selection.
1210    #[must_use]
1211    pub fn exclude_ids(&self) -> &[String] {
1212        &self.servers.exclude_ids
1213    }
1214
1215    // ========================================================================
1216    // Top-level getters
1217    // ========================================================================
1218
1219    /// Custom user agent string (file config only).
1220    #[must_use]
1221    pub fn custom_user_agent(&self) -> Option<&str> {
1222        self.custom_user_agent.as_deref()
1223    }
1224
1225    /// Whether strict validation mode is enabled.
1226    #[must_use]
1227    pub fn strict(&self) -> bool {
1228        self.strict
1229    }
1230}
1231
1232/// Validation result with error details.
1233///
1234/// Uses a builder pattern: start with [`ValidationResult::ok()`], then chain
1235/// [`with_error()`](ValidationResult::with_error) and
1236/// [`with_warning()`](ValidationResult::with_warning) calls.
1237///
1238/// # Example
1239///
1240/// ```
1241/// use netspeed_cli::config::ValidationResult;
1242///
1243/// // Start valid, then add issues via builder chaining
1244/// let result = ValidationResult::ok()
1245///     .with_warning("deprecated option")
1246///     .with_error("invalid profile");
1247///
1248/// assert!(!result.valid);          // errors flip valid to false
1249/// assert_eq!(result.errors.len(), 1);
1250/// assert_eq!(result.warnings.len(), 1);
1251/// ```
1252#[derive(Debug, Clone)]
1253pub struct ValidationResult {
1254    /// Whether validation passed (no errors). Warnings do not affect this.
1255    pub valid: bool,
1256    /// Error messages (any error sets [`valid`](ValidationResult::valid) to `false`).
1257    pub errors: Vec<String>,
1258    /// Warning messages (do not affect [`valid`](ValidationResult::valid)).
1259    pub warnings: Vec<String>,
1260}
1261
1262impl ValidationResult {
1263    /// Create a successful validation result.
1264    ///
1265    /// # Example
1266    ///
1267    /// ```
1268    /// use netspeed_cli::config::ValidationResult;
1269    ///
1270    /// let result = ValidationResult::ok();
1271    /// assert!(result.valid);
1272    /// assert!(result.errors.is_empty());
1273    /// assert!(result.warnings.is_empty());
1274    /// ```
1275    #[must_use]
1276    pub fn ok() -> Self {
1277        Self {
1278            valid: true,
1279            errors: Vec::new(),
1280            warnings: Vec::new(),
1281        }
1282    }
1283
1284    /// Add a warning to the result.
1285    ///
1286    /// Warnings do **not** change [`valid`](ValidationResult::valid) —
1287    /// the result remains passable even with warnings.
1288    ///
1289    /// # Example
1290    ///
1291    /// ```
1292    /// use netspeed_cli::config::ValidationResult;
1293    ///
1294    /// // Warnings preserve valid=true
1295    /// let result = ValidationResult::ok().with_warning("'simple' is deprecated");
1296    /// assert!(result.valid);
1297    /// assert_eq!(result.warnings.len(), 1);
1298    /// assert!(result.warnings[0].contains("deprecated"));
1299    ///
1300    /// // Multiple warnings can be chained
1301    /// let result = ValidationResult::ok()
1302    ///     .with_warning("first warning")
1303    ///     .with_warning("second warning");
1304    /// assert!(result.valid);
1305    /// assert_eq!(result.warnings.len(), 2);
1306    /// ```
1307    #[must_use]
1308    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
1309        self.warnings.push(warning.into());
1310        self
1311    }
1312
1313    /// Create a validation failure.
1314    ///
1315    /// # Example
1316    ///
1317    /// ```
1318    /// use netspeed_cli::config::ValidationResult;
1319    ///
1320    /// let result = ValidationResult::error("invalid profile 'foo'");
1321    /// assert!(!result.valid);
1322    /// assert_eq!(result.errors.len(), 1);
1323    /// assert!(result.errors[0].contains("foo"));
1324    /// assert!(result.warnings.is_empty());
1325    /// ```
1326    #[must_use]
1327    pub fn error(msg: impl Into<String>) -> Self {
1328        Self {
1329            valid: false,
1330            errors: vec![msg.into()],
1331            warnings: Vec::new(),
1332        }
1333    }
1334
1335    /// Add an error to the result.
1336    ///
1337    /// Unlike [`with_warning()`](ValidationResult::with_warning), this flips
1338    /// [`valid`](ValidationResult::valid) to `false`.
1339    ///
1340    /// # Example
1341    ///
1342    /// ```
1343    /// use netspeed_cli::config::ValidationResult;
1344    ///
1345    /// // Adding an error flips valid to false
1346    /// let result = ValidationResult::ok().with_error("bad theme");
1347    /// assert!(!result.valid);
1348    /// assert_eq!(result.errors.len(), 1);
1349    ///
1350    /// // Multiple errors accumulate
1351    /// let result = ValidationResult::error("first error")
1352    ///     .with_error("second error");
1353    /// assert!(!result.valid);
1354    /// assert_eq!(result.errors.len(), 2);
1355    ///
1356    /// // Errors and warnings can be mixed — errors always flip valid
1357    /// let result = ValidationResult::ok()
1358    ///     .with_warning("just a heads-up")
1359    ///     .with_error("actual problem");
1360    /// assert!(!result.valid);
1361    /// assert_eq!(result.warnings.len(), 1);
1362    /// assert_eq!(result.errors.len(), 1);
1363    /// ```
1364    #[must_use]
1365    pub fn with_error(mut self, error: impl Into<String>) -> Self {
1366        self.errors.push(error.into());
1367        self.valid = false;
1368        self
1369    }
1370
1371    /// Merge another validation result into this one.
1372    ///
1373    /// Combines both error and warning lists. If the other result is invalid,
1374    /// this result also becomes invalid.
1375    ///
1376    /// # Example
1377    ///
1378    /// ```
1379    /// use netspeed_cli::config::ValidationResult;
1380    ///
1381    /// // Merging two valid results stays valid
1382    /// let a = ValidationResult::ok().with_warning("warn-a");
1383    /// let b = ValidationResult::ok().with_warning("warn-b");
1384    /// let merged = a.merge(b);
1385    /// assert!(merged.valid);
1386    /// assert_eq!(merged.warnings.len(), 2);
1387    ///
1388    /// // Merging an invalid result makes the whole thing invalid
1389    /// let a = ValidationResult::ok();
1390    /// let b = ValidationResult::error("bad profile");
1391    /// let merged = a.merge(b);
1392    /// assert!(!merged.valid);
1393    /// assert_eq!(merged.errors.len(), 1);
1394    ///
1395    /// // Both errors and warnings are accumulated
1396    /// let a = ValidationResult::error("error-a").with_warning("warn-a");
1397    /// let b = ValidationResult::error("error-b").with_warning("warn-b");
1398    /// let merged = a.merge(b);
1399    /// assert!(!merged.valid);
1400    /// assert_eq!(merged.errors.len(), 2);
1401    /// assert_eq!(merged.warnings.len(), 2);
1402    /// ```
1403    #[must_use]
1404    pub fn merge(mut self, other: ValidationResult) -> Self {
1405        if !other.valid {
1406            self.valid = false;
1407        }
1408        self.errors.extend(other.errors);
1409        self.warnings.extend(other.warnings);
1410        self
1411    }
1412}
1413
1414// Validation delegation — owned by profiles.rs and theme.rs to eliminate duplication
1415
1416/// Validate CSV delimiter character.
1417fn validate_csv_delimiter_config(delimiter: char) -> Result<(), String> {
1418    if !",;|\t".contains(delimiter) {
1419        return Err(format!(
1420            "Invalid CSV delimiter '{}'. Must be one of: comma, semicolon, pipe, or tab",
1421            delimiter
1422        ));
1423    }
1424    Ok(())
1425}
1426
1427/// Validate the entire config structure.
1428pub fn validate_config(file_config: &File) -> ValidationResult {
1429    let mut result = ValidationResult::ok();
1430
1431    // Validate profile — delegation to profiles.rs (DIP: high-level depends on abstraction)
1432    if let Some(ref profile) = file_config.profile {
1433        if let Err(e) = crate::profiles::UserProfile::validate(profile) {
1434            result = result.with_error(e);
1435        }
1436    }
1437
1438    // Validate theme — delegation to theme.rs (DIP: high-level depends on abstraction)
1439    if let Some(ref theme) = file_config.theme {
1440        if let Err(e) = crate::theme::Theme::validate(theme) {
1441            result = result.with_error(e);
1442        }
1443    }
1444
1445    // Validate CSV delimiter
1446    if let Some(delimiter) = file_config.csv_delimiter {
1447        if let Err(e) = validate_csv_delimiter_config(delimiter) {
1448            result = result.with_error(e);
1449        }
1450    }
1451
1452    // Warnings for deprecated options
1453    if file_config.simple.unwrap_or(false) {
1454        result = result.with_warning(
1455            "'simple' option is deprecated. Use '--format simple' instead.".to_string(),
1456        );
1457    }
1458    if file_config.csv.unwrap_or(false) {
1459        result = result
1460            .with_warning("'csv' option is deprecated. Use '--format csv' instead.".to_string());
1461    }
1462    if file_config.json.unwrap_or(false) {
1463        result = result
1464            .with_warning("'json' option is deprecated. Use '--format json' instead.".to_string());
1465    }
1466
1467    result
1468}
1469
1470/// Get the configuration file path (internal — also used by orchestrator for --show-config-path).
1471#[must_use]
1472pub fn get_config_path_internal() -> Option<PathBuf> {
1473    ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
1474        let config_dir = proj_dirs.config_dir();
1475        if let Err(e) = fs::create_dir_all(config_dir) {
1476            eprintln!("Warning: Failed to create config directory: {e}");
1477        }
1478        config_dir.join("config.toml")
1479    })
1480}
1481
1482/// Load the configuration file from the standard config path.
1483///
1484/// Returns `None` if no config file exists or if loading fails.
1485pub fn load_config_file() -> Option<File> {
1486    let path = get_config_path_internal()?;
1487    if !path.exists() {
1488        return None;
1489    }
1490
1491    let content = match fs::read_to_string(&path) {
1492        Ok(c) => c,
1493        Err(e) => {
1494            eprintln!(
1495                "Warning: Failed to read config file {}: {e}",
1496                path.display()
1497            );
1498            return None;
1499        }
1500    };
1501    let mut config: File = match toml::from_str(&content) {
1502        Ok(c) => c,
1503        Err(e) => {
1504            eprintln!("Warning: Failed to parse config: {e}");
1505            return None;
1506        }
1507    };
1508
1509    // Validate timeout if present
1510    if let Some(timeout) = config.timeout {
1511        if timeout == 0 || timeout > 300 {
1512            eprintln!(
1513                "Warning: Invalid config timeout ({timeout}s, must be 1-300). Using default."
1514            );
1515            config.timeout = None;
1516        }
1517    }
1518
1519    Some(config)
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524    use super::*;
1525    use crate::cli::Args;
1526    use clap::Parser;
1527
1528    // ==================== ConfigSource Tests ====================
1529
1530    #[test]
1531    fn test_config_source_from_args_list_flag() {
1532        let args = Args::parse_from(["netspeed-cli", "--list"]);
1533        let source = ConfigSource::from_args(&args);
1534        assert!(source.output.list);
1535    }
1536
1537    #[test]
1538    fn test_config_source_from_args_quiet_flag() {
1539        let args = Args::parse_from(["netspeed-cli", "--quiet"]);
1540        let source = ConfigSource::from_args(&args);
1541        assert_eq!(source.output.quiet, Some(true));
1542    }
1543
1544    #[test]
1545    fn test_config_source_from_args_minimal_flag() {
1546        let args = Args::parse_from(["netspeed-cli", "--minimal"]);
1547        let source = ConfigSource::from_args(&args);
1548        assert_eq!(source.output.minimal, Some(true));
1549    }
1550
1551    #[test]
1552    fn test_config_source_strict_config() {
1553        let args = Args::parse_from(["netspeed-cli", "--strict-config"]);
1554        let source = ConfigSource::from_args(&args);
1555        assert_eq!(source.strict_config, Some(true));
1556    }
1557
1558    #[test]
1559    fn test_config_source_from_args_bytes_flag() {
1560        let args = Args::parse_from(["netspeed-cli", "--bytes"]);
1561        let source = ConfigSource::from_args(&args);
1562        assert_eq!(source.output.bytes, Some(true));
1563    }
1564
1565    #[test]
1566    fn test_config_source_from_args_json_flag() {
1567        let args = Args::parse_from(["netspeed-cli", "--json"]);
1568        let source = ConfigSource::from_args(&args);
1569        assert_eq!(source.output.json, Some(true));
1570    }
1571
1572    #[test]
1573    fn test_config_source_from_args_csv_flag() {
1574        let args = Args::parse_from(["netspeed-cli", "--csv"]);
1575        let source = ConfigSource::from_args(&args);
1576        assert_eq!(source.output.csv, Some(true));
1577    }
1578
1579    #[test]
1580    fn test_config_source_from_args_simple_flag() {
1581        let args = Args::parse_from(["netspeed-cli", "--simple"]);
1582        let source = ConfigSource::from_args(&args);
1583        assert_eq!(source.output.simple, Some(true));
1584    }
1585
1586    #[test]
1587    fn test_config_source_from_args_csv_header_flag() {
1588        let args = Args::parse_from(["netspeed-cli", "--csv-header"]);
1589        let source = ConfigSource::from_args(&args);
1590        assert_eq!(source.output.csv_header, Some(true));
1591    }
1592
1593    #[test]
1594    fn test_config_source_from_args_csv_delimiter() {
1595        let args = Args::parse_from(["netspeed-cli", "--csv-delimiter", ";"]);
1596        let source = ConfigSource::from_args(&args);
1597        assert_eq!(source.output.csv_delimiter, ';');
1598    }
1599
1600    // ==================== Config Creation Tests ====================
1601
1602    #[test]
1603    fn test_config_from_source_with_none_file() {
1604        let source = ConfigSource::default();
1605        let config = Config::from_source_with_file(&source, None);
1606        // Should use defaults when no file config
1607        assert!(!config.output.bytes);
1608        assert_eq!(config.network.timeout, 10);
1609    }
1610
1611    #[test]
1612    fn test_config_from_source_with_file_profile() {
1613        let mut source = ConfigSource::default();
1614        source.output.profile = Some("streamer".to_string());
1615        let file_config = File::default();
1616        let config = Config::from_source_with_file(&source, Some(file_config));
1617        assert_eq!(config.profile(), Some("streamer"));
1618    }
1619
1620    #[test]
1621    fn test_config_from_source_strict_mode() {
1622        let source = ConfigSource {
1623            strict_config: Some(true),
1624            ..Default::default()
1625        };
1626        let file_config = File::default();
1627        let config = Config::from_source_with_file(&source, Some(file_config));
1628        assert!(config.strict());
1629    }
1630
1631    #[test]
1632    fn test_config_from_args_strict_from_file() {
1633        // Test strict mode from file config
1634        let toml_content = "strict = true";
1635        let file_config: File = toml::from_str(toml_content).unwrap();
1636        let source = ConfigSource::default();
1637        let config = Config::from_source_with_file(&source, Some(file_config));
1638        assert!(config.strict());
1639    }
1640
1641    #[test]
1642    fn test_config_from_args_timeout_from_file() {
1643        // Test timeout from file config when CLI is default
1644        let toml_content = "timeout = 60";
1645        let file_config: File = toml::from_str(toml_content).unwrap();
1646        let source = ConfigSource::default(); // timeout=10 (default)
1647        let config = Config::from_source_with_file(&source, Some(file_config));
1648        assert_eq!(config.timeout(), 60); // File should override default
1649    }
1650
1651    #[test]
1652    fn test_config_from_args_timeout_cli_overrides_file() {
1653        // Test CLI timeout overrides file config
1654        let toml_content = "timeout = 60";
1655        let file_config: File = toml::from_str(toml_content).unwrap();
1656        let args = Args::parse_from(["netspeed-cli", "--timeout", "120"]);
1657        let source = ConfigSource::from_args(&args);
1658        let config = Config::from_source_with_file(&source, Some(file_config));
1659        assert_eq!(config.timeout(), 120); // CLI should override file
1660    }
1661
1662    #[test]
1663    fn test_config_from_args_custom_user_agent() {
1664        let toml_content = "custom_user_agent = \"MyAgent/1.0\"";
1665        let file_config: File = toml::from_str(toml_content).unwrap();
1666        let source = ConfigSource::default();
1667        let config = Config::from_source_with_file(&source, Some(file_config));
1668        assert_eq!(config.custom_user_agent(), Some("MyAgent/1.0"));
1669    }
1670
1671    // ==================== OutputConfig::from_source Tests ====================
1672
1673    #[test]
1674    fn test_output_config_from_source_theme_dark_cli_default() {
1675        // When CLI theme is "dark" (default), use file theme
1676        let source = OutputSource {
1677            theme: "dark".to_string(),
1678            ..Default::default()
1679        };
1680        let file = File {
1681            theme: Some("light".to_string()),
1682            ..Default::default()
1683        };
1684        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1685        let output = OutputConfig::from_source(&source, &file, merge_bool);
1686        // Source theme is "dark" -> use file theme
1687        assert_eq!(output.theme, Theme::Light);
1688    }
1689
1690    #[test]
1691    fn test_output_config_from_source_theme_cli_override() {
1692        // When CLI theme is not "dark", use CLI theme
1693        let source = OutputSource {
1694            theme: "light".to_string(),
1695            ..Default::default()
1696        };
1697        let file = File {
1698            theme: Some("high-contrast".to_string()),
1699            ..Default::default()
1700        };
1701        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1702        let output = OutputConfig::from_source(&source, &file, merge_bool);
1703        // Source theme is "light" -> use CLI theme, ignore file
1704        assert_eq!(output.theme, Theme::Light);
1705    }
1706
1707    #[test]
1708    fn test_output_config_from_source_theme_invalid_file_theme() {
1709        // When CLI theme is "dark" but file theme is invalid, use default
1710        let source = OutputSource {
1711            theme: "dark".to_string(),
1712            ..Default::default()
1713        };
1714        let file = File {
1715            theme: Some("invalid_theme".to_string()),
1716            ..Default::default()
1717        };
1718        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1719        let output = OutputConfig::from_source(&source, &file, merge_bool);
1720        // Invalid file theme -> default
1721        assert_eq!(output.theme, Theme::Dark);
1722    }
1723
1724    #[test]
1725    fn test_output_config_from_source_csv_delimiter_default() {
1726        // When CLI delimiter is default ',', use file delimiter
1727        let source = OutputSource::default(); // csv_delimiter = ','
1728        let file = File {
1729            csv_delimiter: Some(';'),
1730            ..Default::default()
1731        };
1732        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1733        let output = OutputConfig::from_source(&source, &file, merge_bool);
1734        assert_eq!(output.csv_delimiter, ';');
1735    }
1736
1737    #[test]
1738    fn test_output_config_from_source_csv_delimiter_cli_override() {
1739        // When CLI delimiter is not default, use CLI
1740        let source = OutputSource {
1741            csv_delimiter: '|',
1742            ..Default::default()
1743        };
1744        let file = File {
1745            csv_delimiter: Some(';'),
1746            ..Default::default()
1747        };
1748        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1749        let output = OutputConfig::from_source(&source, &file, merge_bool);
1750        assert_eq!(output.csv_delimiter, '|');
1751    }
1752
1753    #[test]
1754    fn test_output_config_from_source_profile_merge() {
1755        // Profile: CLI takes precedence over file
1756        let source = OutputSource {
1757            profile: Some("cli-profile".to_string()),
1758            ..Default::default()
1759        };
1760        let file = File {
1761            profile: Some("file-profile".to_string()),
1762            ..Default::default()
1763        };
1764        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1765        let output = OutputConfig::from_source(&source, &file, merge_bool);
1766        assert_eq!(output.profile, Some("cli-profile".to_string()));
1767    }
1768
1769    #[test]
1770    fn test_output_config_from_source_profile_from_file() {
1771        // Profile: when CLI is None, use file
1772        let source = OutputSource::default();
1773        let file = File {
1774            profile: Some("file-profile".to_string()),
1775            ..Default::default()
1776        };
1777        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1778        let output = OutputConfig::from_source(&source, &file, merge_bool);
1779        assert_eq!(output.profile, Some("file-profile".to_string()));
1780    }
1781
1782    #[test]
1783    fn test_output_config_from_source_format() {
1784        let source = OutputSource {
1785            format: Some(Format::Dashboard),
1786            ..Default::default()
1787        };
1788        let file = File::default();
1789        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1790        let output = OutputConfig::from_source(&source, &file, merge_bool);
1791        assert_eq!(output.format, Some(Format::Dashboard));
1792    }
1793
1794    // ==================== TestSelection::from_source Tests ====================
1795
1796    #[test]
1797    fn test_test_selection_from_source_all_fields() {
1798        let source = TestSource {
1799            no_download: Some(true),
1800            no_upload: Some(false),
1801            single: Some(true),
1802        };
1803        let file = File::default();
1804        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1805        let selection = TestSelection::from_source(&source, &file, merge_bool);
1806        assert!(selection.no_download);
1807        assert!(!selection.no_upload);
1808        assert!(selection.single);
1809    }
1810
1811    #[test]
1812    fn test_test_selection_from_source_file_fallback() {
1813        // When CLI is None, use file config
1814        let source = TestSource::default();
1815        let file = File {
1816            no_download: Some(true),
1817            no_upload: Some(true),
1818            single: Some(false),
1819            ..Default::default()
1820        };
1821        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1822        let selection = TestSelection::from_source(&source, &file, merge_bool);
1823        assert!(selection.no_download);
1824        assert!(selection.no_upload);
1825        assert!(!selection.single);
1826    }
1827
1828    #[test]
1829    fn test_test_selection_from_source_both_none() {
1830        // When both CLI and file are None, use default (false)
1831        let source = TestSource::default();
1832        let file = File::default();
1833        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1834        let selection = TestSelection::from_source(&source, &file, merge_bool);
1835        assert!(!selection.no_download);
1836        assert!(!selection.no_upload);
1837        assert!(!selection.single);
1838    }
1839
1840    // ==================== NetworkConfig::from_source Tests ====================
1841
1842    #[test]
1843    fn test_network_config_from_source_all_fields() {
1844        let source = NetworkSource {
1845            source: Some("192.168.1.1".to_string()),
1846            timeout: 60,
1847            ca_cert: Some("/path/to/cert".to_string()),
1848            tls_version: Some("1.3".to_string()),
1849            pin_certs: Some(true),
1850        };
1851        let file = File::default();
1852        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1853        let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
1854            if cli == default {
1855                file.unwrap_or(default)
1856            } else {
1857                cli
1858            }
1859        };
1860        let network = NetworkConfig::from_source(&source, &file, merge_bool, merge_u64);
1861        assert_eq!(network.source, Some("192.168.1.1".to_string()));
1862        assert_eq!(network.timeout, 60);
1863        assert_eq!(network.ca_cert, Some("/path/to/cert".to_string()));
1864        assert_eq!(network.tls_version, Some("1.3".to_string()));
1865        assert!(network.pin_certs);
1866    }
1867
1868    #[test]
1869    fn test_network_config_from_source_timeout_file_fallback() {
1870        // When CLI timeout is default, use file
1871        let source = NetworkSource::default(); // timeout = 10
1872        let file = File {
1873            timeout: Some(30),
1874            ..Default::default()
1875        };
1876        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1877        let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
1878            if cli == default {
1879                file.unwrap_or(default)
1880            } else {
1881                cli
1882            }
1883        };
1884        let network = NetworkConfig::from_source(&source, &file, merge_bool, merge_u64);
1885        assert_eq!(network.timeout, 30);
1886    }
1887
1888    #[test]
1889    fn test_network_config_from_source_ca_cert_file_fallback() {
1890        // When CLI ca_cert is None, use file
1891        let source = NetworkSource::default();
1892        let file = File {
1893            ca_cert: Some("/file/cert.pem".to_string()),
1894            ..Default::default()
1895        };
1896        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1897        let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
1898            if cli == default {
1899                file.unwrap_or(default)
1900            } else {
1901                cli
1902            }
1903        };
1904        let network = NetworkConfig::from_source(&source, &file, merge_bool, merge_u64);
1905        assert_eq!(network.ca_cert, Some("/file/cert.pem".to_string()));
1906    }
1907
1908    #[test]
1909    fn test_network_config_from_source_pin_certs_file_fallback() {
1910        // When CLI pin_certs is None, use file
1911        let source = NetworkSource::default();
1912        let file = File {
1913            pin_certs: Some(true),
1914            ..Default::default()
1915        };
1916        let merge_bool = |cli: Option<bool>, file: Option<bool>| cli.or(file).unwrap_or(false);
1917        let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
1918            if cli == default {
1919                file.unwrap_or(default)
1920            } else {
1921                cli
1922            }
1923        };
1924        let network = NetworkConfig::from_source(&source, &file, merge_bool, merge_u64);
1925        assert!(network.pin_certs);
1926    }
1927
1928    // ==================== ValidationResult Tests ====================
1929
1930    #[test]
1931    fn test_validation_result_multiple_warnings() {
1932        let result = ValidationResult::ok()
1933            .with_warning("warning 1")
1934            .with_warning("warning 2")
1935            .with_warning("warning 3");
1936        assert!(result.valid);
1937        assert_eq!(result.warnings.len(), 3);
1938        assert!(result.errors.is_empty());
1939    }
1940
1941    #[test]
1942    fn test_validation_result_multiple_errors() {
1943        let result = ValidationResult::error("error 1").with_error("error 2");
1944        assert!(!result.valid);
1945        assert_eq!(result.errors.len(), 2);
1946    }
1947
1948    #[test]
1949    fn test_validation_result_with_warning_then_error() {
1950        let result = ValidationResult::ok()
1951            .with_warning("just a warning")
1952            .with_error("actual error");
1953        assert!(!result.valid);
1954        assert!(!result.warnings.is_empty());
1955        assert!(!result.errors.is_empty());
1956    }
1957
1958    #[test]
1959    fn test_validation_result_merge_valid_results() {
1960        let a = ValidationResult::ok().with_warning("warn-a");
1961        let b = ValidationResult::ok().with_warning("warn-b");
1962        let merged = a.merge(b);
1963        assert!(merged.valid);
1964        assert_eq!(merged.warnings.len(), 2);
1965        assert!(merged.errors.is_empty());
1966    }
1967
1968    #[test]
1969    fn test_validation_result_merge_with_invalid() {
1970        let a = ValidationResult::ok();
1971        let b = ValidationResult::error("bad");
1972        let merged = a.merge(b);
1973        assert!(!merged.valid);
1974        assert!(merged.errors.contains(&"bad".to_string()));
1975    }
1976
1977    #[test]
1978    fn test_validation_result_merge_accumulates_all() {
1979        let a = ValidationResult::error("err-a").with_warning("warn-a");
1980        let b = ValidationResult::error("err-b").with_warning("warn-b");
1981        let merged = a.merge(b);
1982        assert!(!merged.valid);
1983        assert_eq!(merged.errors.len(), 2);
1984        assert_eq!(merged.warnings.len(), 2);
1985    }
1986
1987    #[test]
1988    fn test_validation_result_merge_empty() {
1989        let a = ValidationResult::ok();
1990        let merged = a.merge(ValidationResult::ok());
1991        assert!(merged.valid);
1992        assert!(merged.errors.is_empty());
1993        assert!(merged.warnings.is_empty());
1994    }
1995
1996    #[test]
1997    fn test_validation_result_valid_then_invalid() {
1998        // Merging valid into invalid should still be invalid
1999        let a = ValidationResult::error("original error");
2000        let b = ValidationResult::ok();
2001        let merged = a.merge(b);
2002        assert!(!merged.valid);
2003        assert_eq!(merged.errors.len(), 1);
2004    }
2005
2006    // ==================== validate_config Tests ====================
2007
2008    #[test]
2009    fn test_validate_config_all_valid() {
2010        let file_config = File {
2011            profile: Some("power-user".to_string()),
2012            theme: Some("dark".to_string()),
2013            csv_delimiter: Some(','),
2014            ..Default::default()
2015        };
2016        let result = validate_config(&file_config);
2017        assert!(result.valid);
2018        assert!(result.errors.is_empty());
2019        assert!(result.warnings.is_empty());
2020    }
2021
2022    #[test]
2023    fn test_validate_config_multiple_warnings() {
2024        let file_config = File {
2025            simple: Some(true),
2026            csv: Some(true),
2027            json: Some(true),
2028            ..Default::default()
2029        };
2030        let result = validate_config(&file_config);
2031        assert!(result.valid); // Warnings don't invalidate
2032        assert!(result.warnings.len() >= 3); // All deprecated
2033    }
2034
2035    #[test]
2036    fn test_validate_config_invalid_timeout_zero() {
2037        let file_config = File {
2038            timeout: Some(0),
2039            ..Default::default()
2040        };
2041        let result = validate_config(&file_config);
2042        assert!(result.valid); // Timeout is not validated by validate_config
2043    }
2044
2045    #[test]
2046    fn test_validate_config_invalid_timeout_too_large() {
2047        let file_config = File {
2048            timeout: Some(500),
2049            ..Default::default()
2050        };
2051        let result = validate_config(&file_config);
2052        assert!(result.valid); // Timeout validation happens in load_config_file
2053    }
2054
2055    // ==================== load_config_file Tests ====================
2056
2057    #[test]
2058    fn test_load_config_file_invalid_toml() {
2059        // Test with invalid TOML content - temporarily create a test config
2060        // This tests the error handling path
2061        let temp_dir = tempfile::TempDir::new().unwrap();
2062        let config_path = temp_dir.path().join("config.toml");
2063        std::fs::write(&config_path, "invalid toml { =").unwrap();
2064
2065        // Monkey-patch get_config_path_internal for this test
2066        let content = std::fs::read_to_string(&config_path).unwrap();
2067        let result: Result<File, _> = toml::from_str(&content);
2068        assert!(result.is_err());
2069    }
2070
2071    #[test]
2072    fn test_load_config_file_timeout_zero() {
2073        // Timeout 0 is invalid - should be set to None
2074        let temp_dir = tempfile::TempDir::new().unwrap();
2075        let config_path = temp_dir.path().join("config.toml");
2076        std::fs::write(&config_path, "timeout = 0").unwrap();
2077
2078        let content = std::fs::read_to_string(&config_path).unwrap();
2079        let mut config: File = toml::from_str(&content).unwrap();
2080        assert_eq!(config.timeout, Some(0));
2081
2082        // Validate timeout
2083        if let Some(timeout) = config.timeout {
2084            if timeout == 0 || timeout > 300 {
2085                config.timeout = None;
2086            }
2087        }
2088        assert_eq!(config.timeout, None);
2089    }
2090
2091    #[test]
2092    fn test_load_config_file_timeout_too_large() {
2093        // Timeout > 300 is invalid
2094        let temp_dir = tempfile::TempDir::new().unwrap();
2095        let config_path = temp_dir.path().join("config.toml");
2096        std::fs::write(&config_path, "timeout = 500").unwrap();
2097
2098        let content = std::fs::read_to_string(&config_path).unwrap();
2099        let mut config: File = toml::from_str(&content).unwrap();
2100        assert_eq!(config.timeout, Some(500));
2101
2102        // Validate timeout
2103        if let Some(timeout) = config.timeout {
2104            if timeout == 0 || timeout > 300 {
2105                config.timeout = None;
2106            }
2107        }
2108        assert_eq!(config.timeout, None);
2109    }
2110
2111    #[test]
2112    fn test_load_config_file_valid_timeout() {
2113        // Valid timeout should be preserved
2114        let temp_dir = tempfile::TempDir::new().unwrap();
2115        let config_path = temp_dir.path().join("config.toml");
2116        std::fs::write(&config_path, "timeout = 60").unwrap();
2117
2118        let content = std::fs::read_to_string(&config_path).unwrap();
2119        let mut config: File = toml::from_str(&content).unwrap();
2120        assert_eq!(config.timeout, Some(60));
2121
2122        // Validate timeout - should stay as 60
2123        if let Some(timeout) = config.timeout {
2124            if timeout == 0 || timeout > 300 {
2125                config.timeout = None;
2126            }
2127        }
2128        assert_eq!(config.timeout, Some(60));
2129    }
2130
2131    // ==================== get_config_path_internal Tests ====================
2132
2133    #[test]
2134    fn test_get_config_path_internal_returns_path() {
2135        let path = get_config_path_internal();
2136        // Should return a valid path on most systems
2137        if let Some(p) = path {
2138            assert!(p.ends_with("config.toml"));
2139        }
2140        // On some systems, ProjectDirs might not be available
2141    }
2142
2143    // ==================== Config::from_args_with_file Tests ====================
2144
2145    #[test]
2146    fn test_config_from_args_with_file_valid_profile() {
2147        let args = Args::parse_from(["netspeed-cli", "--profile", "gamer"]);
2148        let source = ConfigSource::from_args(&args);
2149        let (config, validation) = Config::from_args_with_file(&source, None);
2150        assert!(validation.valid); // Valid profile
2151        assert!(config.profile().is_some());
2152    }
2153
2154    #[test]
2155    fn test_config_from_args_with_file_invalid_profile_warning() {
2156        let args = Args::parse_from(["netspeed-cli", "--profile", "bad-profile"]);
2157        let source = ConfigSource::from_args(&args);
2158        let (_config, validation) = Config::from_args_with_file(&source, None);
2159        // Invalid profile produces warning, not error
2160        assert!(validation.valid);
2161        assert!(!validation.warnings.is_empty());
2162        assert!(validation.warnings[0].contains("bad-profile"));
2163    }
2164
2165    #[test]
2166    fn test_config_from_args_with_file_preserves_config() {
2167        let args = Args::parse_from(["netspeed-cli", "--timeout", "45"]);
2168        let source = ConfigSource::from_args(&args);
2169        let (config, validation) = Config::from_args_with_file(&source, None);
2170        assert!(validation.valid);
2171        assert_eq!(config.timeout(), 45);
2172    }
2173
2174    #[test]
2175    fn test_config_from_args_with_file_all_formats() {
2176        for format_str in &[
2177            "json",
2178            "jsonl",
2179            "csv",
2180            "minimal",
2181            "simple",
2182            "compact",
2183            "detailed",
2184            "dashboard",
2185        ] {
2186            let args = Args::parse_from(["netspeed-cli", "--format", format_str]);
2187            let source = ConfigSource::from_args(&args);
2188            let (config, _) = Config::from_args_with_file(&source, None);
2189            assert!(
2190                config.format().is_some(),
2191                "Format {} should be set",
2192                format_str
2193            );
2194        }
2195    }
2196
2197    // ==================== Format Display Tests ====================
2198
2199    #[test]
2200    fn test_format_debug() {
2201        let fmt = Format::Json;
2202        let debug_str = format!("{fmt:?}");
2203        assert!(debug_str.contains("Json"));
2204    }
2205
2206    #[test]
2207    fn test_format_clone() {
2208        let fmt = Format::Detailed;
2209        let cloned = fmt;
2210        assert_eq!(fmt, cloned);
2211    }
2212
2213    #[test]
2214    fn test_format_copy() {
2215        let fmt = Format::Dashboard;
2216        let copied = fmt; // Copy, not clone
2217        assert_eq!(fmt, copied);
2218    }
2219
2220    // ==================== Config Debug/Clone Tests ====================
2221
2222    #[test]
2223    fn test_config_debug() {
2224        let config = Config::default();
2225        let debug_str = format!("{config:?}");
2226        assert!(debug_str.contains("Config"));
2227    }
2228
2229    #[test]
2230    fn test_config_source_debug() {
2231        let source = ConfigSource::default();
2232        let debug_str = format!("{source:?}");
2233        assert!(debug_str.contains("ConfigSource"));
2234    }
2235
2236    #[test]
2237    fn test_validation_result_debug() {
2238        let result = ValidationResult::ok();
2239        let debug_str = format!("{result:?}");
2240        assert!(debug_str.contains("ValidationResult"));
2241    }
2242
2243    #[test]
2244    fn test_file_config_debug() {
2245        let file = File::default();
2246        let debug_str = format!("{file:?}");
2247        assert!(debug_str.contains("File"));
2248    }
2249
2250    #[test]
2251    fn test_file_config_clone() {
2252        let file = File {
2253            timeout: Some(45),
2254            profile: Some("test".to_string()),
2255            ..Default::default()
2256        };
2257        let cloned = file.clone();
2258        assert_eq!(file.timeout, cloned.timeout);
2259        assert_eq!(file.profile, cloned.profile);
2260    }
2261
2262    #[test]
2263    fn test_config_source_clone() {
2264        let mut source = ConfigSource::default();
2265        source.output.profile = Some("clone-test".to_string());
2266        let cloned = source.clone();
2267        assert_eq!(source.output.profile, cloned.output.profile);
2268    }
2269
2270    // ==================== File Config Deserialization Edge Cases ====================
2271
2272    #[test]
2273    fn test_file_config_all_fields() {
2274        let toml_content = r#"
2275            no_download = true
2276            no_upload = false
2277            single = true
2278            bytes = true
2279            simple = false
2280            csv = false
2281            csv_delimiter = '|'
2282            csv_header = true
2283            json = false
2284            timeout = 120
2285            profile = "gamer"
2286            theme = "light"
2287            custom_user_agent = "TestAgent/1.0"
2288            strict = true
2289            ca_cert = "/path/to/cert.pem"
2290            tls_version = "1.3"
2291            pin_certs = true
2292        "#;
2293        let config: File = toml::from_str(toml_content).unwrap();
2294        assert_eq!(config.no_download, Some(true));
2295        assert_eq!(config.timeout, Some(120));
2296        assert_eq!(config.profile, Some("gamer".to_string()));
2297        assert_eq!(config.theme, Some("light".to_string()));
2298        assert_eq!(config.custom_user_agent, Some("TestAgent/1.0".to_string()));
2299        assert_eq!(config.strict, Some(true));
2300        assert_eq!(config.ca_cert, Some("/path/to/cert.pem".to_string()));
2301        assert_eq!(config.tls_version, Some("1.3".to_string()));
2302        assert_eq!(config.pin_certs, Some(true));
2303    }
2304
2305    #[test]
2306    fn test_file_config_empty_toml() {
2307        let toml_content = "";
2308        let config: File = toml::from_str(toml_content).unwrap();
2309        assert!(config.no_download.is_none());
2310        assert!(config.timeout.is_none());
2311        assert!(config.profile.is_none());
2312    }
2313
2314    #[test]
2315    fn test_file_config_whitespace_only() {
2316        let toml_content = "   ";
2317        let config: File = toml::from_str(toml_content).unwrap();
2318        assert!(config.no_download.is_none());
2319    }
2320
2321    // ==================== ca_cert_path Tests ====================
2322
2323    #[test]
2324    fn test_ca_cert_path_some() {
2325        let config = Config {
2326            network: NetworkConfig {
2327                ca_cert: Some("/path/to/cert".to_string()),
2328                ..Default::default()
2329            },
2330            ..Default::default()
2331        };
2332        let path = config.ca_cert_path();
2333        assert!(path.is_some());
2334        assert_eq!(path.unwrap(), std::path::PathBuf::from("/path/to/cert"));
2335    }
2336
2337    #[test]
2338    fn test_ca_cert_path_none() {
2339        let config = Config::default();
2340        let path = config.ca_cert_path();
2341        assert!(path.is_none());
2342    }
2343
2344    // ==================== Default Constructors Tests ====================
2345
2346    #[test]
2347    fn test_output_source_debug() {
2348        let src = OutputSource::default();
2349        let debug_str = format!("{src:?}");
2350        assert!(debug_str.contains("OutputSource"));
2351    }
2352
2353    #[test]
2354    fn test_test_source_debug() {
2355        let src = TestSource::default();
2356        let debug_str = format!("{src:?}");
2357        assert!(debug_str.contains("TestSource"));
2358    }
2359
2360    #[test]
2361    fn test_network_source_debug() {
2362        let src = NetworkSource::default();
2363        let debug_str = format!("{src:?}");
2364        assert!(debug_str.contains("NetworkSource"));
2365    }
2366
2367    #[test]
2368    fn test_server_source_debug() {
2369        let src = ServerSource::default();
2370        let debug_str = format!("{src:?}");
2371        assert!(debug_str.contains("ServerSource"));
2372    }
2373
2374    #[test]
2375    fn test_config_source_default() {
2376        let source = ConfigSource::default();
2377        // Verify sub-sources have correct defaults
2378        assert_eq!(source.output.csv_delimiter, ',');
2379        assert_eq!(source.output.theme, "dark");
2380        assert_eq!(source.network.timeout, 10);
2381        assert!(source.test.no_download.is_none());
2382        assert!(source.servers.server_ids.is_empty());
2383    }
2384
2385    #[test]
2386    fn test_config_default() {
2387        let config = Config::default();
2388        // Verify all sub-structs are default
2389        assert!(!config.output.bytes);
2390        assert!(!config.test.no_download);
2391        assert_eq!(config.network.timeout, 10);
2392        assert!(config.servers.server_ids.is_empty());
2393        assert!(!config.strict);
2394    }
2395
2396    // ==================== Deprecated Flags Tests ====================
2397
2398    #[test]
2399    #[allow(deprecated)]
2400    fn test_deprecated_simple_flag() {
2401        let args = Args::parse_from(["netspeed-cli", "--simple"]);
2402        assert_eq!(args.simple, Some(true));
2403        let config = Config::from_args(&args);
2404        assert!(config.simple());
2405    }
2406
2407    #[test]
2408    #[allow(deprecated)]
2409    fn test_deprecated_json_flag() {
2410        let args = Args::parse_from(["netspeed-cli", "--json"]);
2411        assert_eq!(args.json, Some(true));
2412        let config = Config::from_args(&args);
2413        assert!(config.json());
2414    }
2415
2416    #[test]
2417    #[allow(deprecated)]
2418    fn test_deprecated_csv_flag() {
2419        let args = Args::parse_from(["netspeed-cli", "--csv"]);
2420        assert_eq!(args.csv, Some(true));
2421        let config = Config::from_args(&args);
2422        assert!(config.csv());
2423    }
2424
2425    // ==================== Server Selection Tests ====================
2426
2427    #[test]
2428    fn test_server_selection_clone_preserves_data() {
2429        let selection = ServerSelection {
2430            server_ids: vec!["a".to_string(), "b".to_string()],
2431            exclude_ids: vec!["c".to_string()],
2432        };
2433        let cloned = selection.clone();
2434        assert_eq!(selection.server_ids, cloned.server_ids);
2435        assert_eq!(selection.exclude_ids, cloned.exclude_ids);
2436    }
2437
2438    #[test]
2439    fn test_config_server_ids_empty() {
2440        let args = Args::parse_from(["netspeed-cli"]);
2441        let config = Config::from_args(&args);
2442        assert!(config.server_ids().is_empty());
2443    }
2444
2445    #[test]
2446    fn test_config_exclude_ids_empty() {
2447        let args = Args::parse_from(["netspeed-cli"]);
2448        let config = Config::from_args(&args);
2449        assert!(config.exclude_ids().is_empty());
2450    }
2451
2452    // ==================== Original tests (keeping for compatibility) ====================
2453
2454    #[test]
2455    fn test_config_from_args_defaults() {
2456        let args = Args::parse_from(["netspeed-cli"]);
2457        let config = Config::from_args(&args);
2458
2459        assert!(!config.test.no_download);
2460        assert!(!config.test.no_upload);
2461        assert!(!config.test.single);
2462        assert!(!config.output.bytes);
2463        assert!(!config.output.simple);
2464        assert!(!config.output.csv);
2465        assert!(!config.output.json);
2466        assert!(!config.output.list);
2467        assert!(!config.output.quiet);
2468        assert_eq!(config.network.timeout, 10);
2469        assert_eq!(config.output.csv_delimiter, ',');
2470        assert!(!config.output.csv_header);
2471        assert!(config.servers.server_ids.is_empty());
2472        assert!(config.servers.exclude_ids.is_empty());
2473    }
2474
2475    #[test]
2476    fn test_config_from_args_no_download() {
2477        let args = Args::parse_from(["netspeed-cli", "--no-download"]);
2478        let config = Config::from_args(&args);
2479        assert!(config.test.no_download);
2480        assert!(!config.test.no_upload);
2481    }
2482
2483    #[test]
2484    fn test_config_file_deserialization() {
2485        let toml_content = r"
2486            no_download = true
2487            no_upload = false
2488            single = true
2489            bytes = true
2490            simple = false
2491            csv = false
2492            csv_delimiter = ';'
2493            csv_header = true
2494            json = true
2495            timeout = 30
2496        ";
2497
2498        let config: File = toml::from_str(toml_content).unwrap();
2499        assert_eq!(config.no_download, Some(true));
2500        assert_eq!(config.no_upload, Some(false));
2501        assert_eq!(config.single, Some(true));
2502        assert_eq!(config.bytes, Some(true));
2503        assert_eq!(config.simple, Some(false));
2504        assert_eq!(config.csv, Some(false));
2505        assert_eq!(config.csv_delimiter, Some(';'));
2506        assert_eq!(config.csv_header, Some(true));
2507        assert_eq!(config.json, Some(true));
2508        assert_eq!(config.timeout, Some(30));
2509    }
2510
2511    #[test]
2512    fn test_config_file_partial() {
2513        let toml_content = r"
2514            no_download = true
2515            timeout = 20
2516        ";
2517
2518        let config: File = toml::from_str(toml_content).unwrap();
2519        assert_eq!(config.no_download, Some(true));
2520        assert!(config.no_upload.is_none());
2521        assert!(config.single.is_none());
2522        assert_eq!(config.timeout, Some(20));
2523        assert!(config.csv_delimiter.is_none());
2524    }
2525
2526    #[test]
2527    fn test_config_from_args_overrides_file() {
2528        // Test that CLI flags override file config when explicitly set
2529        let args = Args::parse_from(["netspeed-cli", "--no-download"]);
2530        let config = Config::from_args(&args);
2531        assert!(config.test.no_download);
2532    }
2533
2534    #[test]
2535    fn test_config_merge_bool_file_true_cli_false() {
2536        // When CLI omits the flag, the config file value should be used.
2537        let toml_content = r"
2538            no_download = true
2539        ";
2540        let file_config: File = toml::from_str(toml_content).unwrap();
2541
2542        // CLI args omit the flag, so clap yields None for Option<bool>.
2543        let args = Args::parse_from(["netspeed-cli"]);
2544        let file_config_loaded = Some(file_config);
2545
2546        // Manual merge check
2547        let cli_val = args.no_download; // None
2548        let file_val = file_config_loaded.and_then(|c| c.no_download); // Some(true)
2549        let merged = cli_val.or(file_val).unwrap_or(false);
2550        assert!(merged);
2551    }
2552
2553    #[test]
2554    fn test_validate_config_valid_profile() {
2555        let file_config = File {
2556            profile: Some("gamer".to_string()),
2557            ..Default::default()
2558        };
2559        let result = validate_config(&file_config);
2560        assert!(result.valid);
2561        assert!(result.errors.is_empty());
2562    }
2563
2564    #[test]
2565    fn test_validate_config_empty_is_valid() {
2566        // Default case: no config file
2567        let file_config = File::default();
2568        let result = validate_config(&file_config);
2569        assert!(result.valid);
2570        assert!(result.errors.is_empty());
2571        assert!(result.warnings.is_empty());
2572    }
2573
2574    #[test]
2575    fn test_validate_config_invalid_profile() {
2576        let file_config = File {
2577            profile: Some("invalid_profile".to_string()),
2578            ..Default::default()
2579        };
2580        let result = validate_config(&file_config);
2581        assert!(!result.valid);
2582        assert!(!result.errors.is_empty());
2583        assert!(result.errors[0].contains("invalid_profile"));
2584    }
2585
2586    #[test]
2587    fn test_validate_config_invalid_theme() {
2588        let file_config = File {
2589            theme: Some("neon".to_string()),
2590            ..Default::default()
2591        };
2592        let result = validate_config(&file_config);
2593        assert!(!result.valid);
2594        assert!(!result.errors.is_empty());
2595        assert!(result.errors[0].contains("neon"));
2596    }
2597
2598    #[test]
2599    fn test_validate_config_invalid_csv_delimiter() {
2600        let file_config = File {
2601            csv_delimiter: Some('X'),
2602            ..Default::default()
2603        };
2604        let result = validate_config(&file_config);
2605        assert!(!result.valid);
2606        assert!(!result.errors.is_empty());
2607    }
2608
2609    #[test]
2610    fn test_validate_config_deprecated_simple() {
2611        let file_config = File {
2612            simple: Some(true),
2613            ..Default::default()
2614        };
2615        let result = validate_config(&file_config);
2616        assert!(result.valid);
2617        assert!(!result.warnings.is_empty());
2618        assert!(
2619            result
2620                .warnings
2621                .iter()
2622                .any(|w| w.contains("simple") && w.contains("deprecated"))
2623        );
2624    }
2625
2626    #[test]
2627    fn test_validate_config_multiple_issues() {
2628        let file_config = File {
2629            profile: Some("bad".to_string()),
2630            theme: Some("ugly".to_string()),
2631            csv_delimiter: Some('@'),
2632            ..Default::default()
2633        };
2634        let result = validate_config(&file_config);
2635        assert!(!result.valid);
2636        assert!(result.errors.len() >= 3); // profile, theme, delimiter
2637    }
2638
2639    // ==================== TLS Configuration Tests ====================
2640
2641    #[test]
2642    fn test_tls_config_defaults() {
2643        // When no CLI flags or config file, TLS options should be None/false
2644        let args = Args::parse_from(["netspeed-cli"]);
2645        let config = Config::from_args(&args);
2646        assert!(config.network.ca_cert.is_none());
2647        assert!(config.network.tls_version.is_none());
2648        assert!(!config.network.pin_certs);
2649    }
2650
2651    #[test]
2652    fn test_tls_config_file_deserialization() {
2653        // Test that TLS options deserialize correctly from TOML
2654        let toml_content = r#"
2655            ca_cert = "/custom/ca.pem"
2656            tls_version = "1.2"
2657            pin_certs = true
2658        "#;
2659
2660        let file_config: File = toml::from_str(toml_content).unwrap();
2661        assert_eq!(file_config.ca_cert, Some("/custom/ca.pem".to_string()));
2662        assert_eq!(file_config.tls_version, Some("1.2".to_string()));
2663        assert_eq!(file_config.pin_certs, Some(true));
2664    }
2665
2666    #[test]
2667    fn test_tls_config_file_partial() {
2668        // Test partial TLS config from file
2669        let toml_content = r#"
2670            ca_cert = "/my/ca.pem"
2671        "#;
2672
2673        let file_config: File = toml::from_str(toml_content).unwrap();
2674        assert_eq!(file_config.ca_cert, Some("/my/ca.pem".to_string()));
2675        assert!(file_config.tls_version.is_none());
2676        assert!(file_config.pin_certs.is_none());
2677    }
2678
2679    #[test]
2680    fn test_tls_config_cli_ca_cert() {
2681        // Test that --ca-cert CLI flag is parsed correctly
2682        // Use tempfile for guaranteed unique file with automatic cleanup
2683        let temp_file = tempfile::NamedTempFile::new().unwrap();
2684        std::fs::write(temp_file.path(), "fake cert content").unwrap();
2685        let args = Args::parse_from([
2686            "netspeed-cli",
2687            "--ca-cert",
2688            temp_file.path().to_str().unwrap(),
2689        ]);
2690        assert_eq!(
2691            args.ca_cert,
2692            Some(temp_file.path().to_string_lossy().to_string())
2693        );
2694        // tempfile auto-cleans on drop
2695    }
2696
2697    #[test]
2698    fn test_tls_config_cli_tls_version() {
2699        // Test that --tls-version CLI flag is parsed correctly
2700        let args = Args::parse_from(["netspeed-cli", "--tls-version", "1.3"]);
2701        assert_eq!(args.tls_version, Some("1.3".to_string()));
2702    }
2703
2704    #[test]
2705    fn test_tls_config_cli_pin_certs() {
2706        // Test that --pin-certs CLI flag enables pinning
2707        let args = Args::parse_from(["netspeed-cli", "--pin-certs"]);
2708        assert_eq!(args.pin_certs, Some(true));
2709    }
2710
2711    #[test]
2712    fn test_tls_config_cli_pin_certs_false() {
2713        // Test that --pin-certs=false disables pinning
2714        let args = Args::parse_from(["netspeed-cli", "--pin-certs=false"]);
2715        assert_eq!(args.pin_certs, Some(false));
2716    }
2717
2718    #[test]
2719    fn test_tls_config_all_cli_options() {
2720        // Test all TLS options via CLI
2721        // Use tempfile for guaranteed unique file with automatic cleanup
2722        let temp_file = tempfile::NamedTempFile::new().unwrap();
2723        std::fs::write(temp_file.path(), "fake cert content").unwrap();
2724        let args = Args::parse_from([
2725            "netspeed-cli",
2726            "--ca-cert",
2727            temp_file.path().to_str().unwrap(),
2728            "--tls-version",
2729            "1.2",
2730            "--pin-certs",
2731        ]);
2732
2733        assert_eq!(
2734            args.ca_cert,
2735            Some(temp_file.path().to_string_lossy().to_string())
2736        );
2737        assert_eq!(args.tls_version, Some("1.2".to_string()));
2738        assert_eq!(args.pin_certs, Some(true));
2739        // tempfile auto-cleans on drop
2740    }
2741
2742    #[test]
2743    fn test_tls_config_string_merge_cli_takes_precedence() {
2744        // For string options (ca_cert, tls_version), CLI should take precedence
2745        // This is tested by verifying the merge logic:
2746        // ca_cert: args.ca_cert.clone().or(file_config.ca_cert.clone())
2747
2748        // When CLI provides ca_cert, it should be used
2749        let cli_val = Some("/cli/ca.pem".to_string());
2750        let file_val = Some("/file/ca.pem".to_string());
2751        let merged = cli_val.or(file_val.clone());
2752        assert_eq!(merged, Some("/cli/ca.pem".to_string()));
2753
2754        // When CLI is None, file value should be used
2755        let cli_val_none: Option<String> = None;
2756        let merged = cli_val_none.or(file_val.clone());
2757        assert_eq!(merged, Some("/file/ca.pem".to_string()));
2758
2759        // When both are None, result should be None
2760        let merged = Option::<String>::None.or(None);
2761        assert!(merged.is_none());
2762    }
2763
2764    #[test]
2765    fn test_tls_config_bool_merge() {
2766        // Test boolean merge logic for pin_certs
2767        // merge_bool: cli.or(file).unwrap_or(false)
2768        // CLI takes precedence when explicitly set, file used only when CLI is None
2769
2770        // CLI true, file false -> true (CLI takes precedence)
2771        assert!(merge_bool_test(Some(true), Some(false)));
2772
2773        // CLI false, file true -> false (CLI takes precedence even when false)
2774        assert!(!merge_bool_test(Some(false), Some(true)));
2775
2776        // CLI true, file None -> true
2777        assert!(merge_bool_test(Some(true), None));
2778
2779        // CLI false, file None -> false
2780        assert!(!merge_bool_test(Some(false), None));
2781
2782        // CLI None, file true -> true (fall back to file)
2783        assert!(merge_bool_test(None, Some(true)));
2784
2785        // CLI None, file false -> false (fall back to file)
2786        assert!(!merge_bool_test(None, Some(false)));
2787
2788        // CLI None, file None -> false (default)
2789        assert!(!merge_bool_test(None::<bool>, None));
2790    }
2791
2792    // Helper function to test merge_bool logic
2793    fn merge_bool_test(cli: Option<bool>, file: Option<bool>) -> bool {
2794        cli.or(file).unwrap_or(false)
2795    }
2796
2797    // ==================== Format Tests ====================
2798
2799    #[test]
2800    fn test_format_from_cli_type_all_variants() {
2801        use crate::cli::OutputFormatType;
2802        assert_eq!(Format::from_cli_type(OutputFormatType::Json), Format::Json);
2803        assert_eq!(
2804            Format::from_cli_type(OutputFormatType::Jsonl),
2805            Format::Jsonl
2806        );
2807        assert_eq!(Format::from_cli_type(OutputFormatType::Csv), Format::Csv);
2808        assert_eq!(
2809            Format::from_cli_type(OutputFormatType::Minimal),
2810            Format::Minimal
2811        );
2812        assert_eq!(
2813            Format::from_cli_type(OutputFormatType::Simple),
2814            Format::Simple
2815        );
2816        assert_eq!(
2817            Format::from_cli_type(OutputFormatType::Compact),
2818            Format::Compact
2819        );
2820        assert_eq!(
2821            Format::from_cli_type(OutputFormatType::Detailed),
2822            Format::Detailed
2823        );
2824        assert_eq!(
2825            Format::from_cli_type(OutputFormatType::Dashboard),
2826            Format::Dashboard
2827        );
2828    }
2829
2830    #[test]
2831    fn test_format_is_machine_readable() {
2832        assert!(Format::Json.is_machine_readable());
2833        assert!(Format::Jsonl.is_machine_readable());
2834        assert!(Format::Csv.is_machine_readable());
2835        assert!(!Format::Minimal.is_machine_readable());
2836        assert!(!Format::Simple.is_machine_readable());
2837        assert!(!Format::Compact.is_machine_readable());
2838        assert!(!Format::Detailed.is_machine_readable());
2839        assert!(!Format::Dashboard.is_machine_readable());
2840    }
2841
2842    #[test]
2843    fn test_format_is_non_verbose() {
2844        // Non-verbose: everything except Detailed
2845        assert!(Format::Simple.is_non_verbose());
2846        assert!(Format::Minimal.is_non_verbose());
2847        assert!(Format::Compact.is_non_verbose());
2848        assert!(Format::Json.is_non_verbose());
2849        assert!(Format::Jsonl.is_non_verbose());
2850        assert!(Format::Csv.is_non_verbose());
2851        assert!(Format::Dashboard.is_non_verbose());
2852        // Detailed is the only verbose format
2853        assert!(!Format::Detailed.is_non_verbose());
2854    }
2855
2856    #[test]
2857    fn test_format_label() {
2858        assert_eq!(Format::Json.label(), "JSON");
2859        assert_eq!(Format::Jsonl.label(), "JSONL");
2860        assert_eq!(Format::Csv.label(), "CSV");
2861        assert_eq!(Format::Minimal.label(), "Minimal");
2862        assert_eq!(Format::Simple.label(), "Simple");
2863        assert_eq!(Format::Compact.label(), "Compact");
2864        assert_eq!(Format::Detailed.label(), "Detailed");
2865        assert_eq!(Format::Dashboard.label(), "Dashboard");
2866    }
2867
2868    #[test]
2869    fn test_format_display() {
2870        assert_eq!(format!("{}", Format::Json), "JSON");
2871        assert_eq!(format!("{}", Format::Detailed), "Detailed");
2872    }
2873
2874    #[test]
2875    fn test_format_equality() {
2876        assert_eq!(Format::Json, Format::Json);
2877        assert_ne!(Format::Json, Format::Csv);
2878    }
2879
2880    // ==================== ConfigSource Tests ====================
2881
2882    #[test]
2883    fn test_config_source_from_args_defaults() {
2884        let args = Args::parse_from(["netspeed-cli"]);
2885        let source = ConfigSource::from_args(&args);
2886
2887        // Output defaults
2888        assert!(source.output.bytes.is_none());
2889        assert!(source.output.simple.is_none());
2890        assert!(source.output.csv.is_none());
2891        assert_eq!(source.output.csv_delimiter, ',');
2892        assert!(source.output.csv_header.is_none());
2893        assert!(source.output.json.is_none());
2894        assert!(!source.output.list);
2895        assert!(source.output.quiet.is_none());
2896        assert!(source.output.minimal.is_none());
2897        assert!(source.output.profile.is_none());
2898        assert_eq!(source.output.theme, "dark");
2899        assert!(source.output.format.is_none());
2900
2901        // Test selection defaults
2902        assert!(source.test.no_download.is_none());
2903        assert!(source.test.no_upload.is_none());
2904        assert!(source.test.single.is_none());
2905
2906        // Network defaults
2907        assert!(source.network.source.is_none());
2908        assert_eq!(source.network.timeout, 10);
2909        assert!(source.network.ca_cert.is_none());
2910        assert!(source.network.tls_version.is_none());
2911        assert!(source.network.pin_certs.is_none());
2912
2913        // Server defaults
2914        assert!(source.servers.server_ids.is_empty());
2915        assert!(source.servers.exclude_ids.is_empty());
2916
2917        // Top-level
2918        assert!(source.strict_config.is_none());
2919    }
2920
2921    #[test]
2922    fn test_config_source_from_args_all_set() {
2923        let args = Args::parse_from([
2924            "netspeed-cli",
2925            "--bytes",
2926            "--no-download",
2927            "--no-upload",
2928            "--single",
2929            "--timeout",
2930            "30",
2931            "--source",
2932            "0.0.0.0",
2933            "--server",
2934            "1234",
2935            "--exclude",
2936            "5678",
2937            "--profile",
2938            "gamer",
2939            "--theme",
2940            "light",
2941            "--format",
2942            "json",
2943        ]);
2944        let source = ConfigSource::from_args(&args);
2945
2946        assert_eq!(source.output.bytes, Some(true));
2947        assert_eq!(source.test.no_download, Some(true));
2948        assert_eq!(source.test.no_upload, Some(true));
2949        assert_eq!(source.test.single, Some(true));
2950        assert_eq!(source.network.timeout, 30);
2951        assert_eq!(source.network.source, Some("0.0.0.0".to_string()));
2952        assert_eq!(source.servers.server_ids, vec!["1234".to_string()]);
2953        assert_eq!(source.servers.exclude_ids, vec!["5678".to_string()]);
2954        assert_eq!(source.output.profile, Some("gamer".to_string()));
2955        assert_eq!(source.output.theme, "light");
2956        assert_eq!(source.output.format, Some(Format::Json));
2957    }
2958
2959    #[test]
2960    fn test_config_source_format_conversion() {
2961        // Verify that OutputFormatType is properly converted to Format
2962        let args = Args::parse_from(["netspeed-cli", "--format", "csv"]);
2963        let source = ConfigSource::from_args(&args);
2964        assert_eq!(source.output.format, Some(Format::Csv));
2965
2966        let args = Args::parse_from(["netspeed-cli", "--format", "dashboard"]);
2967        let source = ConfigSource::from_args(&args);
2968        assert_eq!(source.output.format, Some(Format::Dashboard));
2969    }
2970
2971    #[test]
2972    fn test_config_source_preserves_option_bools() {
2973        // --no-download=false should yield Some(false), not None
2974        let args = Args::parse_from(["netspeed-cli", "--no-download=false"]);
2975        let source = ConfigSource::from_args(&args);
2976        assert_eq!(source.test.no_download, Some(false));
2977
2978        // No flag should yield None
2979        let args = Args::parse_from(["netspeed-cli"]);
2980        let source = ConfigSource::from_args(&args);
2981        assert!(source.test.no_download.is_none());
2982    }
2983
2984    #[test]
2985    fn test_config_source_default_composes_sub_sources() {
2986        let source = ConfigSource::default();
2987
2988        // Verify each sub-source matches its own Default
2989        assert_eq!(
2990            source.output.csv_delimiter,
2991            OutputSource::default().csv_delimiter
2992        );
2993        assert_eq!(source.output.theme, OutputSource::default().theme);
2994        assert_eq!(source.network.timeout, NetworkSource::default().timeout);
2995        assert!(source.test.no_download.is_none()); // matches TestSource::default()
2996        assert!(source.servers.server_ids.is_empty()); // matches ServerSource::default()
2997
2998        // Verify sub-source fields are accessible through composition
2999        assert!(source.output.bytes.is_none());
3000        assert!(source.network.source.is_none());
3001        assert!(source.strict_config.is_none());
3002    }
3003
3004    // ==================== OutputSource Tests ====================
3005
3006    #[test]
3007    fn test_output_source_default() {
3008        let src = OutputSource::default();
3009        assert!(src.bytes.is_none());
3010        assert!(src.simple.is_none());
3011        assert!(src.csv.is_none());
3012        assert_eq!(src.csv_delimiter, ',');
3013        assert!(src.csv_header.is_none());
3014        assert!(src.json.is_none());
3015        assert!(!src.list);
3016        assert!(src.quiet.is_none());
3017        assert!(src.minimal.is_none());
3018        assert!(src.profile.is_none());
3019        assert_eq!(src.theme, "dark");
3020        assert!(src.format.is_none());
3021    }
3022
3023    #[test]
3024    fn test_output_source_custom() {
3025        let src = OutputSource {
3026            bytes: Some(true),
3027            csv_delimiter: ';',
3028            list: true,
3029            profile: Some("gamer".to_string()),
3030            theme: "light".to_string(),
3031            format: Some(Format::Json),
3032            ..Default::default()
3033        };
3034        assert_eq!(src.bytes, Some(true));
3035        assert_eq!(src.csv_delimiter, ';');
3036        assert!(src.list);
3037        assert_eq!(src.profile, Some("gamer".to_string()));
3038        assert_eq!(src.theme, "light");
3039        assert_eq!(src.format, Some(Format::Json));
3040        // Unset fields still default
3041        assert!(src.simple.is_none());
3042        assert!(src.csv.is_none());
3043        assert!(src.json.is_none());
3044    }
3045
3046    #[test]
3047    fn test_output_source_clone() {
3048        let src = OutputSource {
3049            profile: Some("streamer".to_string()),
3050            ..Default::default()
3051        };
3052        let cloned = src.clone();
3053        assert_eq!(src.profile, cloned.profile);
3054        assert_eq!(src.csv_delimiter, cloned.csv_delimiter);
3055        assert_eq!(src.theme, cloned.theme);
3056    }
3057
3058    // ==================== TestSource Tests ====================
3059
3060    #[test]
3061    fn test_test_source_default() {
3062        let src = TestSource::default();
3063        assert!(src.no_download.is_none());
3064        assert!(src.no_upload.is_none());
3065        assert!(src.single.is_none());
3066    }
3067
3068    #[test]
3069    fn test_test_source_custom() {
3070        let src = TestSource {
3071            no_download: Some(true),
3072            no_upload: Some(false),
3073            single: Some(true),
3074        };
3075        assert_eq!(src.no_download, Some(true));
3076        assert_eq!(src.no_upload, Some(false));
3077        assert_eq!(src.single, Some(true));
3078    }
3079
3080    #[test]
3081    fn test_test_source_clone() {
3082        let src = TestSource {
3083            no_download: Some(true),
3084            ..Default::default()
3085        };
3086        let cloned = src.clone();
3087        assert_eq!(src.no_download, cloned.no_download);
3088    }
3089
3090    // ==================== NetworkSource Tests ====================
3091
3092    #[test]
3093    fn test_network_source_default() {
3094        let src = NetworkSource::default();
3095        assert!(src.source.is_none());
3096        assert_eq!(src.timeout, 10);
3097        assert!(src.ca_cert.is_none());
3098        assert!(src.tls_version.is_none());
3099        assert!(src.pin_certs.is_none());
3100    }
3101
3102    #[test]
3103    fn test_network_source_custom() {
3104        let src = NetworkSource {
3105            source: Some("0.0.0.0".to_string()),
3106            timeout: 60,
3107            ca_cert: Some("/path/to/ca.pem".to_string()),
3108            tls_version: Some("1.3".to_string()),
3109            pin_certs: Some(true),
3110        };
3111        assert_eq!(src.source, Some("0.0.0.0".to_string()));
3112        assert_eq!(src.timeout, 60);
3113        assert_eq!(src.ca_cert, Some("/path/to/ca.pem".to_string()));
3114        assert_eq!(src.tls_version, Some("1.3".to_string()));
3115        assert_eq!(src.pin_certs, Some(true));
3116    }
3117
3118    #[test]
3119    fn test_network_source_clone() {
3120        let src = NetworkSource {
3121            source: Some("192.168.1.1".to_string()),
3122            ..Default::default()
3123        };
3124        let cloned = src.clone();
3125        assert_eq!(src.source, cloned.source);
3126        assert_eq!(src.timeout, cloned.timeout);
3127    }
3128
3129    // ==================== ServerSource Tests ====================
3130
3131    #[test]
3132    fn test_server_source_default() {
3133        let src = ServerSource::default();
3134        assert!(src.server_ids.is_empty());
3135        assert!(src.exclude_ids.is_empty());
3136    }
3137
3138    #[test]
3139    fn test_server_source_custom() {
3140        let src = ServerSource {
3141            server_ids: vec!["1234".to_string()],
3142            exclude_ids: vec!["5678".to_string()],
3143        };
3144        assert_eq!(src.server_ids, vec!["1234".to_string()]);
3145        assert_eq!(src.exclude_ids, vec!["5678".to_string()]);
3146    }
3147
3148    #[test]
3149    fn test_server_source_clone() {
3150        let src = ServerSource {
3151            server_ids: vec!["1234".to_string(), "5678".to_string()],
3152            ..Default::default()
3153        };
3154        let cloned = src.clone();
3155        assert_eq!(src.server_ids, cloned.server_ids);
3156        assert_eq!(src.exclude_ids, cloned.exclude_ids);
3157    }
3158
3159    // ==================== OutputConfig Tests ====================
3160
3161    #[test]
3162    fn test_output_config_default() {
3163        let config = OutputConfig::default();
3164        assert!(!config.bytes);
3165        assert!(!config.simple);
3166        assert!(!config.csv);
3167        assert_eq!(config.csv_delimiter, ',');
3168        assert!(!config.csv_header);
3169        assert!(!config.json);
3170        assert!(!config.list);
3171        assert!(!config.quiet);
3172        assert!(config.profile.is_none());
3173        assert_eq!(config.theme, Theme::Dark);
3174        assert!(!config.minimal);
3175        assert!(config.format.is_none());
3176    }
3177
3178    #[test]
3179    fn test_output_config_clone() {
3180        let config = OutputConfig::default();
3181        let cloned = config.clone();
3182        assert_eq!(config.bytes, cloned.bytes);
3183        assert_eq!(config.csv_delimiter, cloned.csv_delimiter);
3184        assert_eq!(config.theme, cloned.theme);
3185    }
3186
3187    #[test]
3188    fn test_output_config_debug() {
3189        let config = OutputConfig::default();
3190        let debug_str = format!("{config:?}");
3191        assert!(debug_str.contains("OutputConfig"));
3192    }
3193
3194    #[test]
3195    fn test_output_config_custom_theme() {
3196        let custom = OutputConfig {
3197            theme: Theme::Light,
3198            ..Default::default()
3199        };
3200        assert_eq!(custom.theme, Theme::Light);
3201    }
3202
3203    #[test]
3204    fn test_output_config_csv_settings() {
3205        let custom = OutputConfig {
3206            csv: true,
3207            csv_delimiter: ';',
3208            csv_header: true,
3209            ..Default::default()
3210        };
3211        assert!(custom.csv);
3212        assert_eq!(custom.csv_delimiter, ';');
3213        assert!(custom.csv_header);
3214    }
3215
3216    #[test]
3217    fn test_test_selection_defaults() {
3218        let config = TestSelection::default();
3219        assert!(!config.no_download);
3220        assert!(!config.no_upload);
3221        assert!(!config.single);
3222    }
3223
3224    #[test]
3225    fn test_test_selection_skip_tests() {
3226        let custom = TestSelection {
3227            no_download: true,
3228            no_upload: true,
3229            single: true,
3230        };
3231        assert!(custom.no_download);
3232        assert!(custom.no_upload);
3233        assert!(custom.single);
3234    }
3235
3236    #[test]
3237    fn test_output_config_profile() {
3238        let with_profile = OutputConfig {
3239            profile: Some("gamer".to_string()),
3240            ..Default::default()
3241        };
3242        assert_eq!(with_profile.profile, Some("gamer".to_string()));
3243    }
3244
3245    // ==================== NetworkConfig Tests ====================
3246
3247    #[test]
3248    fn test_network_config_default() {
3249        let config = NetworkConfig::default();
3250        assert!(config.source.is_none());
3251        assert_eq!(config.timeout, 10);
3252        assert!(config.ca_cert.is_none());
3253        assert!(config.tls_version.is_none());
3254        assert!(!config.pin_certs);
3255    }
3256
3257    #[test]
3258    fn test_network_config_clone() {
3259        let config = NetworkConfig::default();
3260        let cloned = config.clone();
3261        assert_eq!(config.timeout, cloned.timeout);
3262        assert_eq!(config.pin_certs, cloned.pin_certs);
3263    }
3264
3265    #[test]
3266    fn test_network_config_debug() {
3267        let config = NetworkConfig::default();
3268        let debug_str = format!("{config:?}");
3269        assert!(debug_str.contains("NetworkConfig"));
3270    }
3271
3272    #[test]
3273    fn test_network_config_custom_timeout() {
3274        let custom = NetworkConfig {
3275            timeout: 60,
3276            ..Default::default()
3277        };
3278        assert_eq!(custom.timeout, 60);
3279    }
3280
3281    #[test]
3282    fn test_network_config_source_ip() {
3283        let with_source = NetworkConfig {
3284            source: Some("192.168.1.100".to_string()),
3285            ..Default::default()
3286        };
3287        assert_eq!(with_source.source, Some("192.168.1.100".to_string()));
3288    }
3289
3290    #[test]
3291    fn test_network_config_tls_settings() {
3292        let custom = NetworkConfig {
3293            ca_cert: Some("/path/to/ca.pem".to_string()),
3294            tls_version: Some("1.2".to_string()),
3295            pin_certs: true,
3296            ..Default::default()
3297        };
3298        assert_eq!(custom.ca_cert, Some("/path/to/ca.pem".to_string()));
3299        assert_eq!(custom.tls_version, Some("1.2".to_string()));
3300        assert!(custom.pin_certs);
3301    }
3302
3303    #[test]
3304    fn test_network_config_tls_1_3() {
3305        let custom = NetworkConfig {
3306            tls_version: Some("1.3".to_string()),
3307            pin_certs: true,
3308            ..Default::default()
3309        };
3310        assert_eq!(custom.tls_version, Some("1.3".to_string()));
3311        assert!(custom.pin_certs);
3312    }
3313
3314    // ==================== ServerSelection Tests ====================
3315
3316    #[test]
3317    fn test_server_selection_default() {
3318        let selection = ServerSelection::default();
3319        assert!(selection.server_ids.is_empty());
3320        assert!(selection.exclude_ids.is_empty());
3321    }
3322
3323    #[test]
3324    fn test_server_selection_clone() {
3325        let selection = ServerSelection::default();
3326        let cloned = selection.clone();
3327        assert!(cloned.server_ids.is_empty());
3328        assert!(cloned.exclude_ids.is_empty());
3329    }
3330
3331    #[test]
3332    fn test_server_selection_debug() {
3333        let selection = ServerSelection::default();
3334        let debug_str = format!("{selection:?}");
3335        assert!(debug_str.contains("ServerSelection"));
3336    }
3337
3338    #[test]
3339    fn test_server_selection_specific_ids() {
3340        let selection = ServerSelection {
3341            server_ids: vec!["1234".to_string(), "5678".to_string()],
3342            exclude_ids: Vec::new(),
3343        };
3344        assert_eq!(selection.server_ids.len(), 2);
3345        assert!(selection.exclude_ids.is_empty());
3346    }
3347
3348    #[test]
3349    fn test_server_selection_exclude() {
3350        let selection = ServerSelection {
3351            server_ids: Vec::new(),
3352            exclude_ids: vec!["9999".to_string()],
3353        };
3354        assert!(selection.server_ids.is_empty());
3355        assert_eq!(selection.exclude_ids.len(), 1);
3356        assert_eq!(selection.exclude_ids[0], "9999");
3357    }
3358
3359    #[test]
3360    fn test_server_selection_both() {
3361        let selection = ServerSelection {
3362            server_ids: vec!["1234".to_string()],
3363            exclude_ids: vec!["5678".to_string()],
3364        };
3365        assert_eq!(selection.server_ids.len(), 1);
3366        assert_eq!(selection.exclude_ids.len(), 1);
3367    }
3368
3369    #[test]
3370    fn test_server_selection_from_source_empty() {
3371        let args = Args::parse_from(["netspeed-cli"]);
3372        let source = ConfigSource::from_args(&args);
3373        let selection = ServerSelection::from_source(&source.servers);
3374        assert!(selection.server_ids.is_empty());
3375        assert!(selection.exclude_ids.is_empty());
3376    }
3377
3378    #[test]
3379    fn test_server_selection_from_source_with_servers() {
3380        let args = Args::parse_from(["netspeed-cli", "--server", "1234", "--server", "5678"]);
3381        let source = ConfigSource::from_args(&args);
3382        let selection = ServerSelection::from_source(&source.servers);
3383        assert_eq!(selection.server_ids, vec!["1234", "5678"]);
3384    }
3385
3386    #[test]
3387    fn test_server_selection_from_source_with_excludes() {
3388        let args = Args::parse_from(["netspeed-cli", "--exclude", "9999", "--exclude", "8888"]);
3389        let source = ConfigSource::from_args(&args);
3390        let selection = ServerSelection::from_source(&source.servers);
3391        assert_eq!(selection.exclude_ids, vec!["9999", "8888"]);
3392    }
3393
3394    // ==================== Config Getters Tests ====================
3395
3396    #[test]
3397    fn test_config_getters_match_direct_access() {
3398        let config = Config::default();
3399
3400        // Test execution getters
3401        assert_eq!(config.no_download(), config.test.no_download);
3402        assert_eq!(config.no_upload(), config.test.no_upload);
3403        assert_eq!(config.single(), config.test.single);
3404
3405        // Output getters
3406        assert_eq!(config.bytes(), config.output.bytes);
3407        assert_eq!(config.simple(), config.output.simple);
3408        assert_eq!(config.csv(), config.output.csv);
3409        assert_eq!(config.json(), config.output.json);
3410        assert_eq!(config.quiet(), config.output.quiet);
3411        assert_eq!(config.list(), config.output.list);
3412        assert_eq!(config.minimal(), config.output.minimal);
3413        assert_eq!(config.theme(), config.output.theme);
3414        assert_eq!(config.csv_delimiter(), config.output.csv_delimiter);
3415        assert_eq!(config.csv_header(), config.output.csv_header);
3416        assert_eq!(config.profile(), config.output.profile.as_deref());
3417        assert_eq!(config.format(), config.output.format);
3418
3419        // Network getters
3420        assert_eq!(config.timeout(), config.network.timeout);
3421        assert_eq!(config.source(), config.network.source.as_deref());
3422        assert_eq!(config.ca_cert(), config.network.ca_cert.as_deref());
3423        assert_eq!(config.tls_version(), config.network.tls_version.as_deref());
3424        assert_eq!(config.pin_certs(), config.network.pin_certs);
3425
3426        // Server getters
3427        assert_eq!(config.server_ids(), &config.servers.server_ids[..]);
3428        assert_eq!(config.exclude_ids(), &config.servers.exclude_ids[..]);
3429
3430        // Top-level getters
3431        assert_eq!(
3432            config.custom_user_agent(),
3433            config.custom_user_agent.as_deref()
3434        );
3435        assert_eq!(config.strict(), config.strict);
3436    }
3437
3438    #[test]
3439    fn test_config_getter_returns_for_option_fields() {
3440        // Test that Option getters return correct values
3441        let config = Config {
3442            output: OutputConfig {
3443                profile: Some("gamer".to_string()),
3444                ..Default::default()
3445            },
3446            test: TestSelection {
3447                no_download: false,
3448                no_upload: false,
3449                single: false,
3450            },
3451            network: NetworkConfig {
3452                source: Some("192.168.1.1".to_string()),
3453                ca_cert: Some("/path/to/cert".to_string()),
3454                tls_version: Some("1.3".to_string()),
3455                ..Default::default()
3456            },
3457            servers: ServerSelection {
3458                server_ids: vec!["1234".to_string()],
3459                exclude_ids: vec!["5678".to_string()],
3460            },
3461            custom_user_agent: Some("CustomAgent/1.0".to_string()),
3462            strict: true,
3463        };
3464
3465        // Option getters should return Some(&str)
3466        assert_eq!(config.profile(), Some("gamer"));
3467        assert_eq!(config.source(), Some("192.168.1.1"));
3468        assert_eq!(config.ca_cert(), Some("/path/to/cert"));
3469        assert_eq!(config.tls_version(), Some("1.3"));
3470        assert_eq!(config.custom_user_agent(), Some("CustomAgent/1.0"));
3471
3472        // Slice getters should return &["String"]
3473        assert_eq!(config.server_ids(), ["1234"]);
3474        assert_eq!(config.exclude_ids(), ["5678"]);
3475
3476        // Boolean getters
3477        assert!(!config.pin_certs()); // Default is false, we didn't enable it
3478        assert!(config.strict());
3479    }
3480
3481    #[test]
3482    fn test_config_getters_none_for_unset_options() {
3483        let config = Config::default();
3484
3485        assert_eq!(config.profile(), None);
3486        assert_eq!(config.source(), None);
3487        assert_eq!(config.ca_cert(), None);
3488        assert_eq!(config.tls_version(), None);
3489        assert_eq!(config.custom_user_agent(), None);
3490        assert!(config.server_ids().is_empty());
3491        assert!(config.exclude_ids().is_empty());
3492    }
3493
3494    // ==================== should_save_history Tests ====================
3495
3496    #[test]
3497    fn test_should_save_history_default_format() {
3498        let config = Config::default();
3499        // Default format is None (Detailed) → should save history
3500        assert!(config.should_save_history());
3501    }
3502
3503    #[test]
3504    fn test_should_save_history_json_format() {
3505        let mut config = Config::default();
3506        config.output.format = Some(Format::Json);
3507        assert!(!config.should_save_history());
3508    }
3509
3510    #[test]
3511    fn test_should_save_history_jsonl_format() {
3512        let mut config = Config::default();
3513        config.output.format = Some(Format::Jsonl);
3514        assert!(!config.should_save_history());
3515    }
3516
3517    #[test]
3518    fn test_should_save_history_csv_format() {
3519        let mut config = Config::default();
3520        config.output.format = Some(Format::Csv);
3521        assert!(!config.should_save_history());
3522    }
3523
3524    #[test]
3525    fn test_should_save_history_non_machine_readable_formats() {
3526        // Non-machine-readable formats should still save history
3527        for fmt in [
3528            Format::Minimal,
3529            Format::Simple,
3530            Format::Compact,
3531            Format::Detailed,
3532            Format::Dashboard,
3533        ] {
3534            let mut config = Config::default();
3535            config.output.format = Some(fmt);
3536            assert!(
3537                config.should_save_history(),
3538                "format {:?} should save history",
3539                fmt
3540            );
3541        }
3542    }
3543
3544    #[test]
3545    fn test_should_save_history_legacy_json_flag() {
3546        let mut config = Config::default();
3547        config.output.json = true;
3548        assert!(!config.should_save_history());
3549    }
3550
3551    #[test]
3552    fn test_should_save_history_legacy_csv_flag() {
3553        let mut config = Config::default();
3554        config.output.csv = true;
3555        assert!(!config.should_save_history());
3556    }
3557
3558    #[test]
3559    fn test_should_save_history_both_format_and_legacy() {
3560        // Even if both are set, machine-readable wins
3561        let mut config = Config::default();
3562        config.output.format = Some(Format::Detailed); // human-readable
3563        config.output.json = true; // machine-readable
3564        assert!(!config.should_save_history());
3565    }
3566
3567    #[test]
3568    fn test_should_save_history_verbose_detailed() {
3569        // Detailed format is not machine-readable, should save
3570        let mut config = Config::default();
3571        config.output.format = Some(Format::Detailed);
3572        assert!(config.should_save_history());
3573    }
3574
3575    // ==================== validate_and_report Tests ====================
3576
3577    #[test]
3578    fn test_validate_and_report_with_file_config() {
3579        let source = ConfigSource::default();
3580        let config = Config::from_source(&source);
3581        let file_config = File::default();
3582
3583        // Pass pre-loaded file config to avoid redundant loading
3584        let result = config.validate_and_report(&source, Some(file_config));
3585        assert!(result.valid);
3586    }
3587
3588    #[test]
3589    fn test_validate_and_report_invalid_profile() {
3590        let mut source = ConfigSource::default();
3591        source.output.profile = Some("invalid_profile_xyz".to_string());
3592        let config = Config::from_source(&source);
3593        let file_config = File::default();
3594
3595        let result = config.validate_and_report(&source, Some(file_config));
3596        // Invalid profile from source is a warning, not an error (graceful fallback)
3597        assert!(result.valid); // valid=true because it's a warning, not an error
3598        assert!(!result.warnings.is_empty());
3599        assert!(result.warnings[0].contains("invalid_profile_xyz"));
3600    }
3601
3602    #[test]
3603    fn test_validate_and_report_invalid_file_config() {
3604        let source = ConfigSource::default();
3605        let config = Config::from_source(&source);
3606
3607        // File config with invalid profile is an error
3608        let file_config = File {
3609            profile: Some("bad_profile".to_string()),
3610            ..Default::default()
3611        };
3612
3613        let result = config.validate_and_report(&source, Some(file_config));
3614        assert!(!result.valid);
3615        assert!(!result.errors.is_empty());
3616    }
3617
3618    // ==================== Config Defaults Tests ====================
3619
3620    #[test]
3621    fn test_config_default_composes_sub_structs() {
3622        let config = Config::default();
3623
3624        // Verify sub-struct defaults are composed correctly
3625        assert!(!config.output.bytes); // bytes defaults to false
3626        let _ = config.output;
3627        let _ = config.test;
3628        let _ = config.network;
3629        let _ = config.servers;
3630
3631        // Direct field access still works
3632        assert!(!config.test.no_download);
3633        assert_eq!(config.network.timeout, 10);
3634        assert!(config.servers.server_ids.is_empty());
3635    }
3636
3637    #[test]
3638    fn test_config_clone_preserves_all_fields() {
3639        let config = Config {
3640            output: OutputConfig {
3641                bytes: true,
3642                theme: Theme::Light,
3643                profile: Some("test".to_string()),
3644                ..Default::default()
3645            },
3646            test: TestSelection {
3647                no_download: true,
3648                no_upload: false,
3649                single: true,
3650            },
3651            network: NetworkConfig {
3652                timeout: 30,
3653                source: Some("127.0.0.1".to_string()),
3654                pin_certs: true,
3655                ..Default::default()
3656            },
3657            servers: ServerSelection {
3658                server_ids: vec!["abc".to_string()],
3659                exclude_ids: vec!["xyz".to_string()],
3660            },
3661            custom_user_agent: Some("TestAgent".to_string()),
3662            strict: true,
3663        };
3664
3665        let cloned = config.clone();
3666
3667        // Verify all fields are preserved
3668        assert!(cloned.output.bytes);
3669        assert_eq!(cloned.output.theme, Theme::Light);
3670        assert_eq!(cloned.output.profile, Some("test".to_string()));
3671        assert!(cloned.test.no_download);
3672        assert!(!cloned.test.no_upload);
3673        assert!(cloned.test.single);
3674        assert_eq!(cloned.network.timeout, 30);
3675        assert_eq!(cloned.network.source, Some("127.0.0.1".to_string()));
3676        assert!(cloned.network.pin_certs);
3677        assert_eq!(cloned.servers.server_ids, vec!["abc".to_string()]);
3678        assert_eq!(cloned.servers.exclude_ids, vec!["xyz".to_string()]);
3679        assert_eq!(cloned.custom_user_agent, Some("TestAgent".to_string()));
3680        assert!(cloned.strict);
3681    }
3682}