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