Skip to main content

domain_check_lib/
config.rs

1//! Configuration file parsing and management.
2//!
3//! This module handles loading configuration from TOML files and merging
4//! configurations with proper precedence rules.
5
6use crate::error::DomainCheckError;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Configuration loaded from TOML files.
14///
15/// This represents the structure of configuration files that users can create
16/// to set default values and custom presets.
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct FileConfig {
19    /// Default values for CLI options
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub defaults: Option<DefaultsConfig>,
22
23    /// User-defined TLD presets
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub custom_presets: Option<HashMap<String, Vec<String>>>,
26
27    /// Monitoring configuration (future use)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub monitoring: Option<MonitoringConfig>,
30
31    /// Output formatting preferences
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub output: Option<OutputConfig>,
34
35    /// Domain generation defaults (prefixes/suffixes)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub generation: Option<GenerationConfig>,
38}
39
40/// Default configuration values that map to CLI options.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct DefaultsConfig {
43    /// Default concurrency level
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub concurrency: Option<usize>,
46
47    /// Default TLD preset
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub preset: Option<String>,
50
51    /// Default TLD list (alternative to preset)
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub tlds: Option<Vec<String>>,
54
55    /// Default pretty output
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub pretty: Option<bool>,
58
59    /// Default timeout (as string, e.g., "5s", "30s")
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub timeout: Option<String>,
62
63    /// Default WHOIS fallback setting
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub whois_fallback: Option<bool>,
66
67    /// Default bootstrap setting
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub bootstrap: Option<bool>,
70
71    /// Default detailed info setting
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub detailed_info: Option<bool>,
74}
75
76/// Monitoring configuration (placeholder for future features).
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct MonitoringConfig {
79    /// Monitoring interval
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub interval: Option<String>,
82
83    /// Command to run on changes
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub notify_command: Option<String>,
86}
87
88/// Domain generation configuration.
89#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct GenerationConfig {
91    /// Default prefixes to prepend to domain names
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub prefixes: Option<Vec<String>>,
94
95    /// Default suffixes to append to domain names
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub suffixes: Option<Vec<String>>,
98}
99
100/// Output formatting configuration.
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct OutputConfig {
103    /// Default output format
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub default_format: Option<String>,
106
107    /// Include CSV headers by default
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub csv_headers: Option<bool>,
110
111    /// Pretty-print JSON by default
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub json_pretty: Option<bool>,
114}
115
116/// Configuration discovery and loading functionality.
117pub struct ConfigManager {
118    /// Whether to emit warnings for config issues
119    pub verbose: bool,
120}
121
122impl ConfigManager {
123    /// Create a new configuration manager.
124    pub fn new(verbose: bool) -> Self {
125        Self { verbose }
126    }
127
128    /// Load configuration from a specific file.
129    ///
130    /// # Arguments
131    ///
132    /// * `path` - Path to the configuration file
133    ///
134    /// # Returns
135    ///
136    /// The parsed configuration or an error if parsing fails.
137    pub fn load_file<P: AsRef<Path>>(&self, path: P) -> Result<FileConfig, DomainCheckError> {
138        let path = path.as_ref();
139
140        if !path.exists() {
141            return Err(DomainCheckError::file_error(
142                path.to_string_lossy(),
143                "Configuration file not found",
144            ));
145        }
146
147        let content = fs::read_to_string(path).map_err(|e| {
148            DomainCheckError::file_error(
149                path.to_string_lossy(),
150                format!("Failed to read configuration file: {}", e),
151            )
152        })?;
153
154        let config: FileConfig =
155            toml::from_str(&content).map_err(|e| DomainCheckError::ConfigError {
156                message: format!("Failed to parse TOML configuration: {}", e),
157            })?;
158
159        // Validate the loaded configuration
160        self.validate_config(&config)?;
161
162        Ok(config)
163    }
164
165    /// Discover and load configuration files in precedence order.
166    ///
167    /// Looks for configuration files in standard locations and merges them
168    /// according to precedence rules.
169    ///
170    /// # Returns
171    ///
172    /// Merged configuration from all discovered files.
173    pub fn discover_and_load(&self) -> Result<FileConfig, DomainCheckError> {
174        let mut merged_config = FileConfig::default();
175        let mut loaded_files = Vec::new();
176
177        // 1. Load XDG config (lowest precedence)
178        if let Some(xdg_path) = self.get_xdg_config_path() {
179            if let Ok(config) = self.load_file(&xdg_path) {
180                merged_config = self.merge_configs(merged_config, config);
181                loaded_files.push(xdg_path);
182            }
183        }
184
185        // 2. Load global config
186        if let Some(global_path) = self.get_global_config_path() {
187            if let Ok(config) = self.load_file(&global_path) {
188                merged_config = self.merge_configs(merged_config, config);
189                loaded_files.push(global_path);
190            }
191        }
192
193        // 3. Load local config (highest precedence)
194        if let Some(local_path) = self.get_local_config_path() {
195            if let Ok(config) = self.load_file(&local_path) {
196                merged_config = self.merge_configs(merged_config, config);
197                loaded_files.push(local_path);
198            }
199        }
200
201        // Warn about multiple config files if verbose
202        if self.verbose && loaded_files.len() > 1 {
203            eprintln!("⚠️  Multiple config files found. Using precedence:");
204            for (i, path) in loaded_files.iter().enumerate() {
205                let status = if i == loaded_files.len() - 1 {
206                    "active"
207                } else {
208                    "ignored"
209                };
210                eprintln!("   {} ({})", path.display(), status);
211            }
212        }
213
214        Ok(merged_config)
215    }
216
217    /// Get the local configuration file path.
218    ///
219    /// Looks for configuration files in the current directory.
220    fn get_local_config_path(&self) -> Option<PathBuf> {
221        let candidates = ["./domain-check.toml", "./.domain-check.toml"];
222
223        for candidate in &candidates {
224            let path = Path::new(candidate);
225            if path.exists() {
226                return Some(path.to_path_buf());
227            }
228        }
229
230        None
231    }
232
233    /// Get the global configuration file path.
234    ///
235    /// Looks for configuration files in the user's home directory.
236    fn get_global_config_path(&self) -> Option<PathBuf> {
237        if let Some(home) = env::var_os("HOME") {
238            let candidates = [".domain-check.toml", "domain-check.toml"];
239
240            for candidate in &candidates {
241                let path = Path::new(&home).join(candidate);
242                if path.exists() {
243                    return Some(path);
244                }
245            }
246        }
247
248        None
249    }
250
251    /// Get the XDG configuration file path.
252    ///
253    /// Follows the XDG Base Directory Specification.
254    fn get_xdg_config_path(&self) -> Option<PathBuf> {
255        let config_dir = env::var_os("XDG_CONFIG_HOME")
256            .map(PathBuf::from)
257            .or_else(|| env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
258
259        let path = config_dir.join("domain-check").join("config.toml");
260        if path.exists() {
261            Some(path)
262        } else {
263            None
264        }
265    }
266
267    /// Merge two configurations with proper precedence.
268    ///
269    /// Values from `higher` take precedence over values from `lower`.
270    fn merge_configs(&self, lower: FileConfig, higher: FileConfig) -> FileConfig {
271        FileConfig {
272            defaults: match (lower.defaults, higher.defaults) {
273                (Some(mut lower_defaults), Some(higher_defaults)) => {
274                    // Merge defaults with higher precedence winning
275                    if higher_defaults.concurrency.is_some() {
276                        lower_defaults.concurrency = higher_defaults.concurrency;
277                    }
278                    if higher_defaults.preset.is_some() {
279                        lower_defaults.preset = higher_defaults.preset;
280                    }
281                    if higher_defaults.tlds.is_some() {
282                        lower_defaults.tlds = higher_defaults.tlds;
283                    }
284                    if higher_defaults.pretty.is_some() {
285                        lower_defaults.pretty = higher_defaults.pretty;
286                    }
287                    if higher_defaults.timeout.is_some() {
288                        lower_defaults.timeout = higher_defaults.timeout;
289                    }
290                    if higher_defaults.whois_fallback.is_some() {
291                        lower_defaults.whois_fallback = higher_defaults.whois_fallback;
292                    }
293                    if higher_defaults.bootstrap.is_some() {
294                        lower_defaults.bootstrap = higher_defaults.bootstrap;
295                    }
296                    if higher_defaults.detailed_info.is_some() {
297                        lower_defaults.detailed_info = higher_defaults.detailed_info;
298                    }
299                    Some(lower_defaults)
300                }
301                (None, Some(higher_defaults)) => Some(higher_defaults),
302                (Some(lower_defaults), None) => Some(lower_defaults),
303                (None, None) => None,
304            },
305            custom_presets: match (lower.custom_presets, higher.custom_presets) {
306                (Some(mut lower_presets), Some(higher_presets)) => {
307                    // Merge custom presets, higher precedence wins for conflicts
308                    lower_presets.extend(higher_presets);
309                    Some(lower_presets)
310                }
311                (None, Some(higher_presets)) => Some(higher_presets),
312                (Some(lower_presets), None) => Some(lower_presets),
313                (None, None) => None,
314            },
315            monitoring: higher.monitoring.or(lower.monitoring),
316            output: higher.output.or(lower.output),
317            generation: match (lower.generation, higher.generation) {
318                (Some(mut lower_gen), Some(higher_gen)) => {
319                    if higher_gen.prefixes.is_some() {
320                        lower_gen.prefixes = higher_gen.prefixes;
321                    }
322                    if higher_gen.suffixes.is_some() {
323                        lower_gen.suffixes = higher_gen.suffixes;
324                    }
325                    Some(lower_gen)
326                }
327                (None, Some(higher_gen)) => Some(higher_gen),
328                (Some(lower_gen), None) => Some(lower_gen),
329                (None, None) => None,
330            },
331        }
332    }
333
334    /// Validate a configuration for common issues.
335    fn validate_config(&self, config: &FileConfig) -> Result<(), DomainCheckError> {
336        if let Some(defaults) = &config.defaults {
337            // Validate concurrency
338            if let Some(concurrency) = defaults.concurrency {
339                if concurrency == 0 || concurrency > 100 {
340                    return Err(DomainCheckError::ConfigError {
341                        message: "Concurrency must be between 1 and 100".to_string(),
342                    });
343                }
344            }
345
346            // Validate timeout format
347            if let Some(timeout_str) = &defaults.timeout {
348                if parse_timeout_string(timeout_str).is_none() {
349                    return Err(DomainCheckError::ConfigError {
350                        message: format!(
351                            "Invalid timeout format '{}'. Use format like '5s', '30s', '2m'",
352                            timeout_str
353                        ),
354                    });
355                }
356            }
357
358            // Validate that preset and tlds are not both specified
359            if defaults.preset.is_some() && defaults.tlds.is_some() {
360                return Err(DomainCheckError::ConfigError {
361                    message: "Cannot specify both 'preset' and 'tlds' in defaults".to_string(),
362                });
363            }
364        }
365
366        // Validate custom presets
367        if let Some(presets) = &config.custom_presets {
368            for (name, tlds) in presets {
369                if name.is_empty() {
370                    return Err(DomainCheckError::ConfigError {
371                        message: "Custom preset names cannot be empty".to_string(),
372                    });
373                }
374
375                if tlds.is_empty() {
376                    return Err(DomainCheckError::ConfigError {
377                        message: format!("Custom preset '{}' cannot have empty TLD list", name),
378                    });
379                }
380
381                // Basic TLD format validation
382                for tld in tlds {
383                    if tld.is_empty() || tld.contains('.') || tld.contains(' ') {
384                        return Err(DomainCheckError::ConfigError {
385                            message: format!("Invalid TLD '{}' in preset '{}'", tld, name),
386                        });
387                    }
388                }
389            }
390        }
391
392        Ok(())
393    }
394}
395
396/// Environment variable configuration that mirrors CLI options.
397///
398/// This represents configuration values that can be set via DC_* environment variables.
399#[derive(Debug, Clone, Default)]
400pub struct EnvConfig {
401    pub concurrency: Option<usize>,
402    pub preset: Option<String>,
403    pub tlds: Option<Vec<String>>,
404    pub pretty: Option<bool>,
405    pub timeout: Option<String>,
406    pub whois_fallback: Option<bool>,
407    pub bootstrap: Option<bool>,
408    pub detailed_info: Option<bool>,
409    pub json: Option<bool>,
410    pub csv: Option<bool>,
411    pub file: Option<String>,
412    pub config: Option<String>,
413    pub prefixes: Option<Vec<String>>,
414    pub suffixes: Option<Vec<String>>,
415}
416
417/// Load configuration from environment variables.
418///
419/// Parses all DC_* environment variables and returns a structured configuration.
420/// Invalid values are logged as warnings and ignored.
421///
422/// # Arguments
423///
424/// * `verbose` - Whether to log environment variable usage
425///
426/// # Returns
427///
428/// Parsed environment configuration with validated values.
429pub fn load_env_config(verbose: bool) -> EnvConfig {
430    let mut env_config = EnvConfig::default();
431
432    // DC_CONCURRENCY - concurrent domain checks
433    if let Ok(val) = env::var("DC_CONCURRENCY") {
434        match val.parse::<usize>() {
435            Ok(concurrency) if concurrency > 0 && concurrency <= 100 => {
436                env_config.concurrency = Some(concurrency);
437                if verbose {
438                    println!("🔧 Using DC_CONCURRENCY={}", concurrency);
439                }
440            }
441            _ => {
442                if verbose {
443                    eprintln!("⚠️ Invalid DC_CONCURRENCY='{}', must be 1-100", val);
444                }
445            }
446        }
447    }
448
449    // DC_PRESET - TLD preset name
450    if let Ok(preset) = env::var("DC_PRESET") {
451        if !preset.trim().is_empty() {
452            env_config.preset = Some(preset.clone());
453            if verbose {
454                println!("🔧 Using DC_PRESET={}", preset);
455            }
456        }
457    }
458
459    // DC_TLD - comma-separated TLD list
460    if let Ok(tld_str) = env::var("DC_TLD") {
461        let tlds: Vec<String> = tld_str
462            .split(',')
463            .map(|s| s.trim().to_string())
464            .filter(|s| !s.is_empty())
465            .collect();
466        if !tlds.is_empty() {
467            env_config.tlds = Some(tlds);
468            if verbose {
469                println!("🔧 Using DC_TLD={}", tld_str);
470            }
471        }
472    }
473
474    // DC_PRETTY - enable pretty output
475    if let Ok(val) = env::var("DC_PRETTY") {
476        match val.to_lowercase().as_str() {
477            "true" | "1" | "yes" | "on" => {
478                env_config.pretty = Some(true);
479                if verbose {
480                    println!("🔧 Using DC_PRETTY=true");
481                }
482            }
483            "false" | "0" | "no" | "off" => {
484                env_config.pretty = Some(false);
485                if verbose {
486                    println!("🔧 Using DC_PRETTY=false");
487                }
488            }
489            _ => {
490                if verbose {
491                    eprintln!("⚠️ Invalid DC_PRETTY='{}', use true/false", val);
492                }
493            }
494        }
495    }
496
497    // DC_TIMEOUT - timeout setting
498    if let Ok(timeout_str) = env::var("DC_TIMEOUT") {
499        // Validate timeout format
500        if parse_timeout_string(&timeout_str).is_some() {
501            env_config.timeout = Some(timeout_str.clone());
502            if verbose {
503                println!("🔧 Using DC_TIMEOUT={}", timeout_str);
504            }
505        } else if verbose {
506            eprintln!(
507                "⚠️ Invalid DC_TIMEOUT='{}', use format like '5s', '30s', '2m'",
508                timeout_str
509            );
510        }
511    }
512
513    // DC_WHOIS_FALLBACK - enable/disable WHOIS fallback
514    if let Ok(val) = env::var("DC_WHOIS_FALLBACK") {
515        match val.to_lowercase().as_str() {
516            "true" | "1" | "yes" | "on" => {
517                env_config.whois_fallback = Some(true);
518                if verbose {
519                    println!("🔧 Using DC_WHOIS_FALLBACK=true");
520                }
521            }
522            "false" | "0" | "no" | "off" => {
523                env_config.whois_fallback = Some(false);
524                if verbose {
525                    println!("🔧 Using DC_WHOIS_FALLBACK=false");
526                }
527            }
528            _ => {
529                if verbose {
530                    eprintln!("⚠️ Invalid DC_WHOIS_FALLBACK='{}', use true/false", val);
531                }
532            }
533        }
534    }
535
536    // DC_BOOTSTRAP - enable/disable IANA bootstrap
537    if let Ok(val) = env::var("DC_BOOTSTRAP") {
538        match val.to_lowercase().as_str() {
539            "true" | "1" | "yes" | "on" => {
540                env_config.bootstrap = Some(true);
541                if verbose {
542                    println!("🔧 Using DC_BOOTSTRAP=true");
543                }
544            }
545            "false" | "0" | "no" | "off" => {
546                env_config.bootstrap = Some(false);
547                if verbose {
548                    println!("🔧 Using DC_BOOTSTRAP=false");
549                }
550            }
551            _ => {
552                if verbose {
553                    eprintln!("⚠️ Invalid DC_BOOTSTRAP='{}', use true/false", val);
554                }
555            }
556        }
557    }
558
559    // DC_DETAILED_INFO - enable detailed domain info
560    if let Ok(val) = env::var("DC_DETAILED_INFO") {
561        match val.to_lowercase().as_str() {
562            "true" | "1" | "yes" | "on" => {
563                env_config.detailed_info = Some(true);
564                if verbose {
565                    println!("🔧 Using DC_DETAILED_INFO=true");
566                }
567            }
568            "false" | "0" | "no" | "off" => {
569                env_config.detailed_info = Some(false);
570                if verbose {
571                    println!("🔧 Using DC_DETAILED_INFO=false");
572                }
573            }
574            _ => {
575                if verbose {
576                    eprintln!("⚠️ Invalid DC_DETAILED_INFO='{}', use true/false", val);
577                }
578            }
579        }
580    }
581
582    // DC_JSON - enable JSON output
583    if let Ok(val) = env::var("DC_JSON") {
584        match val.to_lowercase().as_str() {
585            "true" | "1" | "yes" | "on" => {
586                env_config.json = Some(true);
587                if verbose {
588                    println!("🔧 Using DC_JSON=true");
589                }
590            }
591            "false" | "0" | "no" | "off" => {
592                env_config.json = Some(false);
593                if verbose {
594                    println!("🔧 Using DC_JSON=false");
595                }
596            }
597            _ => {
598                if verbose {
599                    eprintln!("⚠️ Invalid DC_JSON='{}', use true/false", val);
600                }
601            }
602        }
603    }
604
605    // DC_CSV - enable CSV output
606    if let Ok(val) = env::var("DC_CSV") {
607        match val.to_lowercase().as_str() {
608            "true" | "1" | "yes" | "on" => {
609                env_config.csv = Some(true);
610                if verbose {
611                    println!("🔧 Using DC_CSV=true");
612                }
613            }
614            "false" | "0" | "no" | "off" => {
615                env_config.csv = Some(false);
616                if verbose {
617                    println!("🔧 Using DC_CSV=false");
618                }
619            }
620            _ => {
621                if verbose {
622                    eprintln!("⚠️ Invalid DC_CSV='{}', use true/false", val);
623                }
624            }
625        }
626    }
627
628    // DC_FILE - default domains file
629    if let Ok(file_path) = env::var("DC_FILE") {
630        if !file_path.trim().is_empty() {
631            env_config.file = Some(file_path.clone());
632            if verbose {
633                println!("🔧 Using DC_FILE={}", file_path);
634            }
635        }
636    }
637
638    // DC_CONFIG - default config file
639    if let Ok(config_path) = env::var("DC_CONFIG") {
640        if !config_path.trim().is_empty() {
641            env_config.config = Some(config_path.clone());
642            if verbose {
643                println!("🔧 Using DC_CONFIG={}", config_path);
644            }
645        }
646    }
647
648    // DC_PREFIX - comma-separated prefixes for domain generation
649    if let Ok(prefix_str) = env::var("DC_PREFIX") {
650        let prefixes: Vec<String> = prefix_str
651            .split(',')
652            .map(|s| s.trim().to_string())
653            .filter(|s| !s.is_empty())
654            .collect();
655        if !prefixes.is_empty() {
656            env_config.prefixes = Some(prefixes);
657            if verbose {
658                println!("🔧 Using DC_PREFIX={}", prefix_str);
659            }
660        }
661    }
662
663    // DC_SUFFIX - comma-separated suffixes for domain generation
664    if let Ok(suffix_str) = env::var("DC_SUFFIX") {
665        let suffixes: Vec<String> = suffix_str
666            .split(',')
667            .map(|s| s.trim().to_string())
668            .filter(|s| !s.is_empty())
669            .collect();
670        if !suffixes.is_empty() {
671            env_config.suffixes = Some(suffixes);
672            if verbose {
673                println!("🔧 Using DC_SUFFIX={}", suffix_str);
674            }
675        }
676    }
677
678    env_config
679}
680
681/// Convert EnvConfig to equivalent CLI arguments format for precedence handling.
682///
683/// This allows environment variables to be processed using the same logic as CLI args.
684impl EnvConfig {
685    /// Get the preset value, checking for conflicts with explicit TLD list.
686    pub fn get_effective_preset(&self) -> Option<String> {
687        // If explicit TLDs are set, preset is ignored
688        if self.tlds.is_some() {
689            None
690        } else {
691            self.preset.clone()
692        }
693    }
694
695    /// Get the effective TLD list, preferring explicit TLDs over preset.
696    pub fn get_effective_tlds(&self) -> Option<Vec<String>> {
697        self.tlds.clone()
698    }
699
700    /// Check if output format conflicts exist (JSON and CSV both set).
701    pub fn has_output_format_conflict(&self) -> bool {
702        matches!((self.json, self.csv), (Some(true), Some(true)))
703    }
704}
705
706/// Parse a timeout string like "5s", "30s", "2m" into seconds.
707///
708/// # Arguments
709///
710/// * `timeout_str` - String representation of timeout
711///
712/// # Returns
713///
714/// Number of seconds, or None if parsing fails.
715fn parse_timeout_string(timeout_str: &str) -> Option<u64> {
716    let timeout_str = timeout_str.trim().to_lowercase();
717
718    if timeout_str.ends_with('s') {
719        timeout_str
720            .strip_suffix('s')
721            .and_then(|s| s.parse::<u64>().ok())
722    } else if timeout_str.ends_with('m') {
723        timeout_str
724            .strip_suffix('m')
725            .and_then(|s| s.parse::<u64>().ok())
726            .map(|m| m * 60)
727    } else {
728        // Assume seconds if no unit
729        timeout_str.parse::<u64>().ok()
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use std::io::Write;
737    use tempfile::NamedTempFile;
738
739    // ── Helper ──────────────────────────────────────────────────────────
740
741    fn write_temp_config(content: &str) -> NamedTempFile {
742        let mut f = NamedTempFile::new().unwrap();
743        f.write_all(content.as_bytes()).unwrap();
744        f.flush().unwrap();
745        f
746    }
747
748    // ── parse_timeout_string ────────────────────────────────────────────
749
750    #[test]
751    fn test_parse_timeout_seconds_with_suffix() {
752        assert_eq!(parse_timeout_string("5s"), Some(5));
753        assert_eq!(parse_timeout_string("30s"), Some(30));
754        assert_eq!(parse_timeout_string("0s"), Some(0));
755        assert_eq!(parse_timeout_string("999s"), Some(999));
756    }
757
758    #[test]
759    fn test_parse_timeout_minutes() {
760        assert_eq!(parse_timeout_string("2m"), Some(120));
761        assert_eq!(parse_timeout_string("1m"), Some(60));
762        assert_eq!(parse_timeout_string("0m"), Some(0));
763    }
764
765    #[test]
766    fn test_parse_timeout_bare_number() {
767        assert_eq!(parse_timeout_string("5"), Some(5));
768        assert_eq!(parse_timeout_string("0"), Some(0));
769        assert_eq!(parse_timeout_string("120"), Some(120));
770    }
771
772    #[test]
773    fn test_parse_timeout_whitespace_trimmed() {
774        assert_eq!(parse_timeout_string("  5s  "), Some(5));
775        assert_eq!(parse_timeout_string(" 2m "), Some(120));
776    }
777
778    #[test]
779    fn test_parse_timeout_case_insensitive() {
780        assert_eq!(parse_timeout_string("5S"), Some(5));
781        assert_eq!(parse_timeout_string("2M"), Some(120));
782    }
783
784    #[test]
785    fn test_parse_timeout_invalid() {
786        assert_eq!(parse_timeout_string("invalid"), None);
787        assert_eq!(parse_timeout_string("abc"), None);
788        assert_eq!(parse_timeout_string("s"), None);
789        assert_eq!(parse_timeout_string("m"), None);
790        assert_eq!(parse_timeout_string(""), None);
791        assert_eq!(parse_timeout_string("-5s"), None);
792    }
793
794    // ── FileConfig defaults ─────────────────────────────────────────────
795
796    #[test]
797    fn test_file_config_default_all_none() {
798        let config = FileConfig::default();
799        assert!(config.defaults.is_none());
800        assert!(config.custom_presets.is_none());
801        assert!(config.monitoring.is_none());
802        assert!(config.output.is_none());
803        assert!(config.generation.is_none());
804    }
805
806    #[test]
807    fn test_defaults_config_default_all_none() {
808        let defaults = DefaultsConfig::default();
809        assert!(defaults.concurrency.is_none());
810        assert!(defaults.preset.is_none());
811        assert!(defaults.tlds.is_none());
812        assert!(defaults.pretty.is_none());
813        assert!(defaults.timeout.is_none());
814        assert!(defaults.whois_fallback.is_none());
815        assert!(defaults.bootstrap.is_none());
816        assert!(defaults.detailed_info.is_none());
817    }
818
819    // ── ConfigManager::load_file ────────────────────────────────────────
820
821    #[test]
822    fn test_load_valid_config_full() {
823        let f = write_temp_config(
824            r#"
825[defaults]
826concurrency = 25
827preset = "startup"
828pretty = true
829timeout = "10s"
830whois_fallback = false
831bootstrap = true
832detailed_info = true
833
834[custom_presets]
835my_preset = ["com", "org", "io"]
836"#,
837        );
838
839        let manager = ConfigManager::new(false);
840        let config = manager.load_file(f.path()).unwrap();
841
842        let defaults = config.defaults.unwrap();
843        assert_eq!(defaults.concurrency, Some(25));
844        assert_eq!(defaults.preset, Some("startup".to_string()));
845        assert_eq!(defaults.pretty, Some(true));
846        assert_eq!(defaults.timeout, Some("10s".to_string()));
847        assert_eq!(defaults.whois_fallback, Some(false));
848        assert_eq!(defaults.bootstrap, Some(true));
849        assert_eq!(defaults.detailed_info, Some(true));
850
851        let presets = config.custom_presets.unwrap();
852        assert_eq!(
853            presets.get("my_preset"),
854            Some(&vec!["com".into(), "org".into(), "io".into()])
855        );
856    }
857
858    #[test]
859    fn test_load_empty_config() {
860        let f = write_temp_config("");
861        let manager = ConfigManager::new(false);
862        let config = manager.load_file(f.path()).unwrap();
863        assert!(config.defaults.is_none());
864        assert!(config.custom_presets.is_none());
865    }
866
867    #[test]
868    fn test_load_minimal_defaults_only() {
869        let f = write_temp_config(
870            r#"
871[defaults]
872concurrency = 50
873"#,
874        );
875        let manager = ConfigManager::new(false);
876        let config = manager.load_file(f.path()).unwrap();
877        let defaults = config.defaults.unwrap();
878        assert_eq!(defaults.concurrency, Some(50));
879        assert!(defaults.preset.is_none());
880    }
881
882    #[test]
883    fn test_load_nonexistent_file() {
884        let manager = ConfigManager::new(false);
885        let result = manager.load_file("/tmp/nonexistent_domain_check_config_xyz.toml");
886        assert!(result.is_err());
887        let err = result.unwrap_err();
888        assert!(err.to_string().contains("not found"));
889    }
890
891    #[test]
892    fn test_load_invalid_toml() {
893        let f = write_temp_config("this is not [valid toml ===");
894        let manager = ConfigManager::new(false);
895        let result = manager.load_file(f.path());
896        assert!(result.is_err());
897        let err = result.unwrap_err();
898        assert!(err.to_string().contains("TOML"));
899    }
900
901    // ── Validation: concurrency ─────────────────────────────────────────
902
903    #[test]
904    fn test_validate_concurrency_zero() {
905        let f = write_temp_config("[defaults]\nconcurrency = 0\n");
906        let manager = ConfigManager::new(false);
907        let result = manager.load_file(f.path());
908        assert!(result.is_err());
909        assert!(result
910            .unwrap_err()
911            .to_string()
912            .contains("between 1 and 100"));
913    }
914
915    #[test]
916    fn test_validate_concurrency_over_100() {
917        let f = write_temp_config("[defaults]\nconcurrency = 101\n");
918        let manager = ConfigManager::new(false);
919        let result = manager.load_file(f.path());
920        assert!(result.is_err());
921        assert!(result
922            .unwrap_err()
923            .to_string()
924            .contains("between 1 and 100"));
925    }
926
927    #[test]
928    fn test_validate_concurrency_boundary_1() {
929        let f = write_temp_config("[defaults]\nconcurrency = 1\n");
930        let manager = ConfigManager::new(false);
931        assert!(manager.load_file(f.path()).is_ok());
932    }
933
934    #[test]
935    fn test_validate_concurrency_boundary_100() {
936        let f = write_temp_config("[defaults]\nconcurrency = 100\n");
937        let manager = ConfigManager::new(false);
938        assert!(manager.load_file(f.path()).is_ok());
939    }
940
941    // ── Validation: timeout ─────────────────────────────────────────────
942
943    #[test]
944    fn test_validate_timeout_invalid_format() {
945        let f = write_temp_config("[defaults]\ntimeout = \"abc\"\n");
946        let manager = ConfigManager::new(false);
947        let result = manager.load_file(f.path());
948        assert!(result.is_err());
949        assert!(result.unwrap_err().to_string().contains("Invalid timeout"));
950    }
951
952    #[test]
953    fn test_validate_timeout_valid_seconds() {
954        let f = write_temp_config("[defaults]\ntimeout = \"30s\"\n");
955        let manager = ConfigManager::new(false);
956        assert!(manager.load_file(f.path()).is_ok());
957    }
958
959    #[test]
960    fn test_validate_timeout_valid_minutes() {
961        let f = write_temp_config("[defaults]\ntimeout = \"2m\"\n");
962        let manager = ConfigManager::new(false);
963        assert!(manager.load_file(f.path()).is_ok());
964    }
965
966    #[test]
967    fn test_validate_timeout_bare_number_valid() {
968        let f = write_temp_config("[defaults]\ntimeout = \"10\"\n");
969        let manager = ConfigManager::new(false);
970        assert!(manager.load_file(f.path()).is_ok());
971    }
972
973    // ── Validation: preset + tlds conflict ──────────────────────────────
974
975    #[test]
976    fn test_validate_preset_and_tlds_conflict() {
977        let f = write_temp_config(
978            r#"
979[defaults]
980preset = "startup"
981tlds = ["com", "org"]
982"#,
983        );
984        let manager = ConfigManager::new(false);
985        let result = manager.load_file(f.path());
986        assert!(result.is_err());
987        assert!(result
988            .unwrap_err()
989            .to_string()
990            .contains("Cannot specify both"));
991    }
992
993    #[test]
994    fn test_validate_preset_alone_ok() {
995        let f = write_temp_config("[defaults]\npreset = \"startup\"\n");
996        let manager = ConfigManager::new(false);
997        assert!(manager.load_file(f.path()).is_ok());
998    }
999
1000    #[test]
1001    fn test_validate_tlds_alone_ok() {
1002        let f = write_temp_config("[defaults]\ntlds = [\"com\", \"org\"]\n");
1003        let manager = ConfigManager::new(false);
1004        assert!(manager.load_file(f.path()).is_ok());
1005    }
1006
1007    // ── Validation: custom presets ──────────────────────────────────────
1008
1009    #[test]
1010    fn test_validate_custom_preset_empty_name() {
1011        let manager = ConfigManager::new(false);
1012        let config = FileConfig {
1013            custom_presets: Some(HashMap::from([("".to_string(), vec!["com".to_string()])])),
1014            ..Default::default()
1015        };
1016        let result = manager.validate_config(&config);
1017        assert!(result.is_err());
1018        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1019    }
1020
1021    #[test]
1022    fn test_validate_custom_preset_empty_tld_list() {
1023        let manager = ConfigManager::new(false);
1024        let config = FileConfig {
1025            custom_presets: Some(HashMap::from([("mypreset".to_string(), vec![])])),
1026            ..Default::default()
1027        };
1028        let result = manager.validate_config(&config);
1029        assert!(result.is_err());
1030        assert!(result.unwrap_err().to_string().contains("empty TLD list"));
1031    }
1032
1033    #[test]
1034    fn test_validate_custom_preset_invalid_tld_with_dot() {
1035        let manager = ConfigManager::new(false);
1036        let config = FileConfig {
1037            custom_presets: Some(HashMap::from([(
1038                "bad".to_string(),
1039                vec!["co.uk".to_string()],
1040            )])),
1041            ..Default::default()
1042        };
1043        let result = manager.validate_config(&config);
1044        assert!(result.is_err());
1045        assert!(result.unwrap_err().to_string().contains("Invalid TLD"));
1046    }
1047
1048    #[test]
1049    fn test_validate_custom_preset_invalid_tld_with_space() {
1050        let manager = ConfigManager::new(false);
1051        let config = FileConfig {
1052            custom_presets: Some(HashMap::from([(
1053                "bad".to_string(),
1054                vec!["c om".to_string()],
1055            )])),
1056            ..Default::default()
1057        };
1058        let result = manager.validate_config(&config);
1059        assert!(result.is_err());
1060        assert!(result.unwrap_err().to_string().contains("Invalid TLD"));
1061    }
1062
1063    #[test]
1064    fn test_validate_custom_preset_invalid_tld_empty_string() {
1065        let manager = ConfigManager::new(false);
1066        let config = FileConfig {
1067            custom_presets: Some(HashMap::from([("bad".to_string(), vec!["".to_string()])])),
1068            ..Default::default()
1069        };
1070        let result = manager.validate_config(&config);
1071        assert!(result.is_err());
1072        assert!(result.unwrap_err().to_string().contains("Invalid TLD"));
1073    }
1074
1075    #[test]
1076    fn test_validate_valid_custom_preset() {
1077        let manager = ConfigManager::new(false);
1078        let config = FileConfig {
1079            custom_presets: Some(HashMap::from([(
1080                "mypreset".to_string(),
1081                vec!["com".to_string(), "org".to_string()],
1082            )])),
1083            ..Default::default()
1084        };
1085        assert!(manager.validate_config(&config).is_ok());
1086    }
1087
1088    // ── merge_configs ───────────────────────────────────────────────────
1089
1090    #[test]
1091    fn test_merge_defaults_higher_wins() {
1092        let manager = ConfigManager::new(false);
1093        let lower = FileConfig {
1094            defaults: Some(DefaultsConfig {
1095                concurrency: Some(10),
1096                preset: Some("startup".to_string()),
1097                pretty: Some(false),
1098                ..Default::default()
1099            }),
1100            ..Default::default()
1101        };
1102        let higher = FileConfig {
1103            defaults: Some(DefaultsConfig {
1104                concurrency: Some(25),
1105                pretty: Some(true),
1106                ..Default::default()
1107            }),
1108            ..Default::default()
1109        };
1110
1111        let merged = manager.merge_configs(lower, higher);
1112        let defaults = merged.defaults.unwrap();
1113        assert_eq!(defaults.concurrency, Some(25));
1114        assert_eq!(defaults.preset, Some("startup".to_string()));
1115        assert_eq!(defaults.pretty, Some(true));
1116    }
1117
1118    #[test]
1119    fn test_merge_defaults_lower_none() {
1120        let manager = ConfigManager::new(false);
1121        let lower = FileConfig::default();
1122        let higher = FileConfig {
1123            defaults: Some(DefaultsConfig {
1124                concurrency: Some(50),
1125                ..Default::default()
1126            }),
1127            ..Default::default()
1128        };
1129
1130        let merged = manager.merge_configs(lower, higher);
1131        assert_eq!(merged.defaults.unwrap().concurrency, Some(50));
1132    }
1133
1134    #[test]
1135    fn test_merge_defaults_higher_none() {
1136        let manager = ConfigManager::new(false);
1137        let lower = FileConfig {
1138            defaults: Some(DefaultsConfig {
1139                concurrency: Some(10),
1140                ..Default::default()
1141            }),
1142            ..Default::default()
1143        };
1144        let higher = FileConfig::default();
1145
1146        let merged = manager.merge_configs(lower, higher);
1147        assert_eq!(merged.defaults.unwrap().concurrency, Some(10));
1148    }
1149
1150    #[test]
1151    fn test_merge_defaults_both_none() {
1152        let manager = ConfigManager::new(false);
1153        let merged = manager.merge_configs(FileConfig::default(), FileConfig::default());
1154        assert!(merged.defaults.is_none());
1155    }
1156
1157    #[test]
1158    fn test_merge_all_default_fields() {
1159        let manager = ConfigManager::new(false);
1160        let lower = FileConfig {
1161            defaults: Some(DefaultsConfig {
1162                concurrency: Some(10),
1163                preset: Some("lower".to_string()),
1164                tlds: Some(vec!["com".to_string()]),
1165                pretty: Some(false),
1166                timeout: Some("5s".to_string()),
1167                whois_fallback: Some(true),
1168                bootstrap: Some(false),
1169                detailed_info: Some(false),
1170            }),
1171            ..Default::default()
1172        };
1173        let higher = FileConfig {
1174            defaults: Some(DefaultsConfig {
1175                concurrency: Some(50),
1176                preset: Some("higher".to_string()),
1177                tlds: Some(vec!["org".to_string()]),
1178                pretty: Some(true),
1179                timeout: Some("30s".to_string()),
1180                whois_fallback: Some(false),
1181                bootstrap: Some(true),
1182                detailed_info: Some(true),
1183            }),
1184            ..Default::default()
1185        };
1186
1187        let merged = manager.merge_configs(lower, higher);
1188        let d = merged.defaults.unwrap();
1189        assert_eq!(d.concurrency, Some(50));
1190        assert_eq!(d.preset, Some("higher".to_string()));
1191        assert_eq!(d.tlds, Some(vec!["org".to_string()]));
1192        assert_eq!(d.pretty, Some(true));
1193        assert_eq!(d.timeout, Some("30s".to_string()));
1194        assert_eq!(d.whois_fallback, Some(false));
1195        assert_eq!(d.bootstrap, Some(true));
1196        assert_eq!(d.detailed_info, Some(true));
1197    }
1198
1199    #[test]
1200    fn test_merge_custom_presets_combined() {
1201        let manager = ConfigManager::new(false);
1202        let lower = FileConfig {
1203            custom_presets: Some(HashMap::from([
1204                ("a".to_string(), vec!["com".to_string()]),
1205                ("shared".to_string(), vec!["net".to_string()]),
1206            ])),
1207            ..Default::default()
1208        };
1209        let higher = FileConfig {
1210            custom_presets: Some(HashMap::from([
1211                ("b".to_string(), vec!["org".to_string()]),
1212                ("shared".to_string(), vec!["io".to_string()]),
1213            ])),
1214            ..Default::default()
1215        };
1216
1217        let merged = manager.merge_configs(lower, higher);
1218        let presets = merged.custom_presets.unwrap();
1219        assert_eq!(presets.get("a"), Some(&vec!["com".to_string()]));
1220        assert_eq!(presets.get("b"), Some(&vec!["org".to_string()]));
1221        // Higher wins on conflict
1222        assert_eq!(presets.get("shared"), Some(&vec!["io".to_string()]));
1223    }
1224
1225    #[test]
1226    fn test_merge_custom_presets_lower_none() {
1227        let manager = ConfigManager::new(false);
1228        let lower = FileConfig::default();
1229        let higher = FileConfig {
1230            custom_presets: Some(HashMap::from([("a".to_string(), vec!["com".to_string()])])),
1231            ..Default::default()
1232        };
1233        let merged = manager.merge_configs(lower, higher);
1234        assert!(merged.custom_presets.is_some());
1235    }
1236
1237    #[test]
1238    fn test_merge_custom_presets_higher_none() {
1239        let manager = ConfigManager::new(false);
1240        let lower = FileConfig {
1241            custom_presets: Some(HashMap::from([("a".to_string(), vec!["com".to_string()])])),
1242            ..Default::default()
1243        };
1244        let higher = FileConfig::default();
1245        let merged = manager.merge_configs(lower, higher);
1246        assert!(merged.custom_presets.is_some());
1247    }
1248
1249    #[test]
1250    fn test_merge_monitoring_higher_wins() {
1251        let manager = ConfigManager::new(false);
1252        let lower = FileConfig {
1253            monitoring: Some(MonitoringConfig {
1254                interval: Some("5m".to_string()),
1255                notify_command: None,
1256            }),
1257            ..Default::default()
1258        };
1259        let higher = FileConfig {
1260            monitoring: Some(MonitoringConfig {
1261                interval: Some("10m".to_string()),
1262                notify_command: Some("echo done".to_string()),
1263            }),
1264            ..Default::default()
1265        };
1266        let merged = manager.merge_configs(lower, higher);
1267        let mon = merged.monitoring.unwrap();
1268        assert_eq!(mon.interval, Some("10m".to_string()));
1269        assert_eq!(mon.notify_command, Some("echo done".to_string()));
1270    }
1271
1272    #[test]
1273    fn test_merge_output_higher_wins() {
1274        let manager = ConfigManager::new(false);
1275        let lower = FileConfig {
1276            output: Some(OutputConfig {
1277                default_format: Some("json".to_string()),
1278                csv_headers: Some(true),
1279                json_pretty: None,
1280            }),
1281            ..Default::default()
1282        };
1283        let higher = FileConfig {
1284            output: Some(OutputConfig {
1285                default_format: Some("csv".to_string()),
1286                csv_headers: None,
1287                json_pretty: Some(true),
1288            }),
1289            ..Default::default()
1290        };
1291        let merged = manager.merge_configs(lower, higher);
1292        let out = merged.output.unwrap();
1293        // monitoring/output use simple `or` — entire higher section replaces lower
1294        assert_eq!(out.default_format, Some("csv".to_string()));
1295    }
1296
1297    #[test]
1298    fn test_merge_generation_higher_prefixes_win() {
1299        let manager = ConfigManager::new(false);
1300        let lower = FileConfig {
1301            generation: Some(GenerationConfig {
1302                prefixes: Some(vec!["get".to_string()]),
1303                suffixes: Some(vec!["hub".to_string()]),
1304            }),
1305            ..Default::default()
1306        };
1307        let higher = FileConfig {
1308            generation: Some(GenerationConfig {
1309                prefixes: Some(vec!["my".to_string(), "the".to_string()]),
1310                suffixes: None,
1311            }),
1312            ..Default::default()
1313        };
1314
1315        let merged = manager.merge_configs(lower, higher);
1316        let gen = merged.generation.unwrap();
1317        assert_eq!(
1318            gen.prefixes,
1319            Some(vec!["my".to_string(), "the".to_string()])
1320        );
1321        assert_eq!(gen.suffixes, Some(vec!["hub".to_string()]));
1322    }
1323
1324    #[test]
1325    fn test_merge_generation_both_none() {
1326        let manager = ConfigManager::new(false);
1327        let merged = manager.merge_configs(FileConfig::default(), FileConfig::default());
1328        assert!(merged.generation.is_none());
1329    }
1330
1331    #[test]
1332    fn test_merge_generation_lower_none() {
1333        let manager = ConfigManager::new(false);
1334        let higher = FileConfig {
1335            generation: Some(GenerationConfig {
1336                prefixes: Some(vec!["get".to_string()]),
1337                suffixes: None,
1338            }),
1339            ..Default::default()
1340        };
1341        let merged = manager.merge_configs(FileConfig::default(), higher);
1342        assert!(merged.generation.is_some());
1343    }
1344
1345    #[test]
1346    fn test_merge_generation_higher_none() {
1347        let manager = ConfigManager::new(false);
1348        let lower = FileConfig {
1349            generation: Some(GenerationConfig {
1350                prefixes: None,
1351                suffixes: Some(vec!["ly".to_string()]),
1352            }),
1353            ..Default::default()
1354        };
1355        let merged = manager.merge_configs(lower, FileConfig::default());
1356        assert_eq!(
1357            merged.generation.unwrap().suffixes,
1358            Some(vec!["ly".to_string()])
1359        );
1360    }
1361
1362    // ── load_file with generation + output + monitoring ─────────────────
1363
1364    #[test]
1365    fn test_load_generation_config() {
1366        let f = write_temp_config(
1367            r#"
1368[defaults]
1369concurrency = 20
1370
1371[generation]
1372prefixes = ["get", "my"]
1373suffixes = ["hub", "ly"]
1374"#,
1375        );
1376        let manager = ConfigManager::new(false);
1377        let config = manager.load_file(f.path()).unwrap();
1378        let gen = config.generation.unwrap();
1379        assert_eq!(gen.prefixes, Some(vec!["get".into(), "my".into()]));
1380        assert_eq!(gen.suffixes, Some(vec!["hub".into(), "ly".into()]));
1381    }
1382
1383    #[test]
1384    fn test_load_output_config() {
1385        let f = write_temp_config(
1386            r#"
1387[output]
1388default_format = "json"
1389csv_headers = true
1390json_pretty = false
1391"#,
1392        );
1393        let manager = ConfigManager::new(false);
1394        let config = manager.load_file(f.path()).unwrap();
1395        let out = config.output.unwrap();
1396        assert_eq!(out.default_format, Some("json".to_string()));
1397        assert_eq!(out.csv_headers, Some(true));
1398        assert_eq!(out.json_pretty, Some(false));
1399    }
1400
1401    #[test]
1402    fn test_load_monitoring_config() {
1403        let f = write_temp_config(
1404            r#"
1405[monitoring]
1406interval = "5m"
1407notify_command = "echo done"
1408"#,
1409        );
1410        let manager = ConfigManager::new(false);
1411        let config = manager.load_file(f.path()).unwrap();
1412        let mon = config.monitoring.unwrap();
1413        assert_eq!(mon.interval, Some("5m".to_string()));
1414        assert_eq!(mon.notify_command, Some("echo done".to_string()));
1415    }
1416
1417    // ── TOML serialization round-trip ───────────────────────────────────
1418
1419    #[test]
1420    fn test_file_config_serialization_skip_none() {
1421        let config = FileConfig::default();
1422        let toml_str = toml::to_string(&config).unwrap();
1423        // All None fields should be skipped, resulting in empty-ish output
1424        assert!(!toml_str.contains("defaults"));
1425        assert!(!toml_str.contains("custom_presets"));
1426    }
1427
1428    #[test]
1429    fn test_file_config_round_trip() {
1430        let config = FileConfig {
1431            defaults: Some(DefaultsConfig {
1432                concurrency: Some(25),
1433                preset: Some("tech".to_string()),
1434                ..Default::default()
1435            }),
1436            custom_presets: Some(HashMap::from([(
1437                "mine".to_string(),
1438                vec!["com".to_string(), "io".to_string()],
1439            )])),
1440            ..Default::default()
1441        };
1442        let toml_str = toml::to_string(&config).unwrap();
1443        let parsed: FileConfig = toml::from_str(&toml_str).unwrap();
1444        assert_eq!(parsed.defaults.unwrap().concurrency, Some(25));
1445        assert!(parsed.custom_presets.unwrap().contains_key("mine"));
1446    }
1447
1448    // ── EnvConfig methods ───────────────────────────────────────────────
1449
1450    #[test]
1451    fn test_env_config_default() {
1452        let env = EnvConfig::default();
1453        assert!(env.concurrency.is_none());
1454        assert!(env.preset.is_none());
1455        assert!(env.tlds.is_none());
1456        assert!(env.pretty.is_none());
1457        assert!(env.timeout.is_none());
1458        assert!(env.json.is_none());
1459        assert!(env.csv.is_none());
1460        assert!(env.file.is_none());
1461        assert!(env.config.is_none());
1462        assert!(env.prefixes.is_none());
1463        assert!(env.suffixes.is_none());
1464    }
1465
1466    #[test]
1467    fn test_get_effective_preset_no_tlds() {
1468        let env = EnvConfig {
1469            preset: Some("startup".to_string()),
1470            tlds: None,
1471            ..Default::default()
1472        };
1473        assert_eq!(env.get_effective_preset(), Some("startup".to_string()));
1474    }
1475
1476    #[test]
1477    fn test_get_effective_preset_with_tlds_returns_none() {
1478        let env = EnvConfig {
1479            preset: Some("startup".to_string()),
1480            tlds: Some(vec!["com".to_string()]),
1481            ..Default::default()
1482        };
1483        // When explicit TLDs are set, preset is ignored
1484        assert_eq!(env.get_effective_preset(), None);
1485    }
1486
1487    #[test]
1488    fn test_get_effective_preset_neither_set() {
1489        let env = EnvConfig::default();
1490        assert_eq!(env.get_effective_preset(), None);
1491    }
1492
1493    #[test]
1494    fn test_get_effective_tlds() {
1495        let env = EnvConfig {
1496            tlds: Some(vec!["com".to_string(), "org".to_string()]),
1497            ..Default::default()
1498        };
1499        assert_eq!(
1500            env.get_effective_tlds(),
1501            Some(vec!["com".to_string(), "org".to_string()])
1502        );
1503    }
1504
1505    #[test]
1506    fn test_get_effective_tlds_none() {
1507        let env = EnvConfig::default();
1508        assert_eq!(env.get_effective_tlds(), None);
1509    }
1510
1511    #[test]
1512    fn test_has_output_format_conflict_both_true() {
1513        let env = EnvConfig {
1514            json: Some(true),
1515            csv: Some(true),
1516            ..Default::default()
1517        };
1518        assert!(env.has_output_format_conflict());
1519    }
1520
1521    #[test]
1522    fn test_has_output_format_conflict_one_true() {
1523        let env = EnvConfig {
1524            json: Some(true),
1525            csv: Some(false),
1526            ..Default::default()
1527        };
1528        assert!(!env.has_output_format_conflict());
1529    }
1530
1531    #[test]
1532    fn test_has_output_format_conflict_both_false() {
1533        let env = EnvConfig {
1534            json: Some(false),
1535            csv: Some(false),
1536            ..Default::default()
1537        };
1538        assert!(!env.has_output_format_conflict());
1539    }
1540
1541    #[test]
1542    fn test_has_output_format_conflict_none() {
1543        let env = EnvConfig::default();
1544        assert!(!env.has_output_format_conflict());
1545    }
1546
1547    #[test]
1548    fn test_has_output_format_conflict_one_none_one_true() {
1549        let env = EnvConfig {
1550            json: Some(true),
1551            csv: None,
1552            ..Default::default()
1553        };
1554        assert!(!env.has_output_format_conflict());
1555    }
1556
1557    // ── load_env_config ─────────────────────────────────────────────────
1558    //
1559    // Env var mutations are process-global, so tests that touch DC_* vars
1560    // must be serialized. We use a mutex to prevent races.
1561
1562    use std::sync::Mutex;
1563
1564    static ENV_MUTEX: Mutex<()> = Mutex::new(());
1565
1566    fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
1567        let _lock = ENV_MUTEX.lock().unwrap();
1568        // Clean slate: remove all DC_* vars first
1569        for key in &[
1570            "DC_CONCURRENCY",
1571            "DC_PRESET",
1572            "DC_TLD",
1573            "DC_PRETTY",
1574            "DC_TIMEOUT",
1575            "DC_WHOIS_FALLBACK",
1576            "DC_BOOTSTRAP",
1577            "DC_DETAILED_INFO",
1578            "DC_JSON",
1579            "DC_CSV",
1580            "DC_FILE",
1581            "DC_CONFIG",
1582            "DC_PREFIX",
1583            "DC_SUFFIX",
1584        ] {
1585            env::remove_var(key);
1586        }
1587        // Set requested vars
1588        for (k, v) in vars {
1589            env::set_var(k, v);
1590        }
1591        f();
1592        // Clean up
1593        for (k, _) in vars {
1594            env::remove_var(k);
1595        }
1596    }
1597
1598    #[test]
1599    fn test_load_env_concurrency_valid() {
1600        with_env_vars(&[("DC_CONCURRENCY", "50")], || {
1601            let config = load_env_config(false);
1602            assert_eq!(config.concurrency, Some(50));
1603        });
1604    }
1605
1606    #[test]
1607    fn test_load_env_concurrency_zero_ignored() {
1608        with_env_vars(&[("DC_CONCURRENCY", "0")], || {
1609            let config = load_env_config(false);
1610            assert!(config.concurrency.is_none());
1611        });
1612    }
1613
1614    #[test]
1615    fn test_load_env_concurrency_over_100_ignored() {
1616        with_env_vars(&[("DC_CONCURRENCY", "200")], || {
1617            let config = load_env_config(false);
1618            assert!(config.concurrency.is_none());
1619        });
1620    }
1621
1622    #[test]
1623    fn test_load_env_concurrency_non_numeric_ignored() {
1624        with_env_vars(&[("DC_CONCURRENCY", "abc")], || {
1625            let config = load_env_config(false);
1626            assert!(config.concurrency.is_none());
1627        });
1628    }
1629
1630    #[test]
1631    fn test_load_env_preset() {
1632        with_env_vars(&[("DC_PRESET", "startup")], || {
1633            let config = load_env_config(false);
1634            assert_eq!(config.preset, Some("startup".to_string()));
1635        });
1636    }
1637
1638    #[test]
1639    fn test_load_env_preset_empty_ignored() {
1640        with_env_vars(&[("DC_PRESET", "   ")], || {
1641            let config = load_env_config(false);
1642            assert!(config.preset.is_none());
1643        });
1644    }
1645
1646    #[test]
1647    fn test_load_env_tld() {
1648        with_env_vars(&[("DC_TLD", "com,org,io")], || {
1649            let config = load_env_config(false);
1650            assert_eq!(
1651                config.tlds,
1652                Some(vec!["com".into(), "org".into(), "io".into()])
1653            );
1654        });
1655    }
1656
1657    #[test]
1658    fn test_load_env_tld_with_spaces() {
1659        with_env_vars(&[("DC_TLD", " com , org , io ")], || {
1660            let config = load_env_config(false);
1661            assert_eq!(
1662                config.tlds,
1663                Some(vec!["com".into(), "org".into(), "io".into()])
1664            );
1665        });
1666    }
1667
1668    #[test]
1669    fn test_load_env_tld_empty_entries_filtered() {
1670        with_env_vars(&[("DC_TLD", "com,,org,")], || {
1671            let config = load_env_config(false);
1672            assert_eq!(config.tlds, Some(vec!["com".into(), "org".into()]));
1673        });
1674    }
1675
1676    #[test]
1677    fn test_load_env_pretty_true_variants() {
1678        for val in &["true", "1", "yes", "on", "TRUE", "Yes"] {
1679            with_env_vars(&[("DC_PRETTY", val)], || {
1680                let config = load_env_config(false);
1681                assert_eq!(
1682                    config.pretty,
1683                    Some(true),
1684                    "DC_PRETTY={} should be true",
1685                    val
1686                );
1687            });
1688        }
1689    }
1690
1691    #[test]
1692    fn test_load_env_pretty_false_variants() {
1693        for val in &["false", "0", "no", "off", "FALSE", "No"] {
1694            with_env_vars(&[("DC_PRETTY", val)], || {
1695                let config = load_env_config(false);
1696                assert_eq!(
1697                    config.pretty,
1698                    Some(false),
1699                    "DC_PRETTY={} should be false",
1700                    val
1701                );
1702            });
1703        }
1704    }
1705
1706    #[test]
1707    fn test_load_env_pretty_invalid_ignored() {
1708        with_env_vars(&[("DC_PRETTY", "maybe")], || {
1709            let config = load_env_config(false);
1710            assert!(config.pretty.is_none());
1711        });
1712    }
1713
1714    #[test]
1715    fn test_load_env_timeout_valid() {
1716        with_env_vars(&[("DC_TIMEOUT", "30s")], || {
1717            let config = load_env_config(false);
1718            assert_eq!(config.timeout, Some("30s".to_string()));
1719        });
1720    }
1721
1722    #[test]
1723    fn test_load_env_timeout_invalid_ignored() {
1724        with_env_vars(&[("DC_TIMEOUT", "invalid")], || {
1725            let config = load_env_config(false);
1726            assert!(config.timeout.is_none());
1727        });
1728    }
1729
1730    #[test]
1731    fn test_load_env_boolean_flags() {
1732        with_env_vars(
1733            &[
1734                ("DC_WHOIS_FALLBACK", "true"),
1735                ("DC_BOOTSTRAP", "false"),
1736                ("DC_DETAILED_INFO", "1"),
1737                ("DC_JSON", "yes"),
1738                ("DC_CSV", "off"),
1739            ],
1740            || {
1741                let config = load_env_config(false);
1742                assert_eq!(config.whois_fallback, Some(true));
1743                assert_eq!(config.bootstrap, Some(false));
1744                assert_eq!(config.detailed_info, Some(true));
1745                assert_eq!(config.json, Some(true));
1746                assert_eq!(config.csv, Some(false));
1747            },
1748        );
1749    }
1750
1751    #[test]
1752    fn test_load_env_file() {
1753        with_env_vars(&[("DC_FILE", "/path/to/domains.txt")], || {
1754            let config = load_env_config(false);
1755            assert_eq!(config.file, Some("/path/to/domains.txt".to_string()));
1756        });
1757    }
1758
1759    #[test]
1760    fn test_load_env_file_empty_ignored() {
1761        with_env_vars(&[("DC_FILE", "  ")], || {
1762            let config = load_env_config(false);
1763            assert!(config.file.is_none());
1764        });
1765    }
1766
1767    #[test]
1768    fn test_load_env_config_path() {
1769        with_env_vars(&[("DC_CONFIG", "/etc/dc.toml")], || {
1770            let config = load_env_config(false);
1771            assert_eq!(config.config, Some("/etc/dc.toml".to_string()));
1772        });
1773    }
1774
1775    #[test]
1776    fn test_load_env_prefix_suffix() {
1777        with_env_vars(
1778            &[("DC_PREFIX", "get,my,try"), ("DC_SUFFIX", "hub,ly")],
1779            || {
1780                let config = load_env_config(false);
1781                assert_eq!(
1782                    config.prefixes,
1783                    Some(vec!["get".into(), "my".into(), "try".into()])
1784                );
1785                assert_eq!(config.suffixes, Some(vec!["hub".into(), "ly".into()]));
1786            },
1787        );
1788    }
1789
1790    #[test]
1791    fn test_load_env_no_vars_returns_all_none() {
1792        with_env_vars(&[], || {
1793            let config = load_env_config(false);
1794            assert!(config.concurrency.is_none());
1795            assert!(config.preset.is_none());
1796            assert!(config.tlds.is_none());
1797            assert!(config.pretty.is_none());
1798            assert!(config.timeout.is_none());
1799            assert!(config.whois_fallback.is_none());
1800            assert!(config.bootstrap.is_none());
1801            assert!(config.detailed_info.is_none());
1802            assert!(config.json.is_none());
1803            assert!(config.csv.is_none());
1804            assert!(config.file.is_none());
1805            assert!(config.config.is_none());
1806            assert!(config.prefixes.is_none());
1807            assert!(config.suffixes.is_none());
1808        });
1809    }
1810
1811    // ── ConfigManager verbose flag ──────────────────────────────────────
1812
1813    #[test]
1814    fn test_config_manager_verbose_flag() {
1815        let manager = ConfigManager::new(true);
1816        assert!(manager.verbose);
1817        let manager = ConfigManager::new(false);
1818        assert!(!manager.verbose);
1819    }
1820}