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    #[test]
740    fn test_parse_timeout_string() {
741        assert_eq!(parse_timeout_string("5s"), Some(5));
742        assert_eq!(parse_timeout_string("30s"), Some(30));
743        assert_eq!(parse_timeout_string("2m"), Some(120));
744        assert_eq!(parse_timeout_string("5"), Some(5));
745        assert_eq!(parse_timeout_string("invalid"), None);
746    }
747
748    #[test]
749    fn test_load_valid_config() {
750        let config_content = r#"
751[defaults]
752concurrency = 25
753preset = "startup"
754pretty = true
755
756[custom_presets]
757my_preset = ["com", "org", "io"]
758"#;
759
760        let mut temp_file = NamedTempFile::new().unwrap();
761        temp_file.write_all(config_content.as_bytes()).unwrap();
762        temp_file.flush().unwrap();
763
764        let manager = ConfigManager::new(false);
765        let config = manager.load_file(temp_file.path()).unwrap();
766
767        assert!(config.defaults.is_some());
768        let defaults = config.defaults.unwrap();
769        assert_eq!(defaults.concurrency, Some(25));
770        assert_eq!(defaults.preset, Some("startup".to_string()));
771        assert_eq!(defaults.pretty, Some(true));
772
773        assert!(config.custom_presets.is_some());
774        let presets = config.custom_presets.unwrap();
775        assert_eq!(
776            presets.get("my_preset"),
777            Some(&vec![
778                "com".to_string(),
779                "org".to_string(),
780                "io".to_string()
781            ])
782        );
783    }
784
785    #[test]
786    fn test_invalid_concurrency() {
787        let config_content = r#"
788[defaults]
789concurrency = 0
790"#;
791
792        let mut temp_file = NamedTempFile::new().unwrap();
793        temp_file.write_all(config_content.as_bytes()).unwrap();
794        temp_file.flush().unwrap();
795
796        let manager = ConfigManager::new(false);
797        let result = manager.load_file(temp_file.path());
798        assert!(result.is_err());
799    }
800
801    #[test]
802    fn test_merge_configs() {
803        let manager = ConfigManager::new(false);
804
805        let lower = FileConfig {
806            defaults: Some(DefaultsConfig {
807                concurrency: Some(10),
808                preset: Some("startup".to_string()),
809                pretty: Some(false),
810                ..Default::default()
811            }),
812            ..Default::default()
813        };
814
815        let higher = FileConfig {
816            defaults: Some(DefaultsConfig {
817                concurrency: Some(25),
818                pretty: Some(true),
819                ..Default::default()
820            }),
821            ..Default::default()
822        };
823
824        let merged = manager.merge_configs(lower, higher);
825        let defaults = merged.defaults.unwrap();
826
827        assert_eq!(defaults.concurrency, Some(25)); // Higher wins
828        assert_eq!(defaults.preset, Some("startup".to_string())); // Lower preserved
829        assert_eq!(defaults.pretty, Some(true)); // Higher wins
830    }
831
832    #[test]
833    fn test_load_generation_config() {
834        let config_content = r#"
835[defaults]
836concurrency = 20
837
838[generation]
839prefixes = ["get", "my"]
840suffixes = ["hub", "ly"]
841"#;
842
843        let mut temp_file = NamedTempFile::new().unwrap();
844        temp_file.write_all(config_content.as_bytes()).unwrap();
845        temp_file.flush().unwrap();
846
847        let manager = ConfigManager::new(false);
848        let config = manager.load_file(temp_file.path()).unwrap();
849
850        assert!(config.generation.is_some());
851        let gen = config.generation.unwrap();
852        assert_eq!(
853            gen.prefixes,
854            Some(vec!["get".to_string(), "my".to_string()])
855        );
856        assert_eq!(
857            gen.suffixes,
858            Some(vec!["hub".to_string(), "ly".to_string()])
859        );
860    }
861
862    #[test]
863    fn test_merge_generation_configs() {
864        let manager = ConfigManager::new(false);
865
866        let lower = FileConfig {
867            generation: Some(GenerationConfig {
868                prefixes: Some(vec!["get".to_string()]),
869                suffixes: Some(vec!["hub".to_string()]),
870            }),
871            ..Default::default()
872        };
873
874        let higher = FileConfig {
875            generation: Some(GenerationConfig {
876                prefixes: Some(vec!["my".to_string(), "the".to_string()]),
877                suffixes: None,
878            }),
879            ..Default::default()
880        };
881
882        let merged = manager.merge_configs(lower, higher);
883        let gen = merged.generation.unwrap();
884
885        // Higher prefixes win
886        assert_eq!(
887            gen.prefixes,
888            Some(vec!["my".to_string(), "the".to_string()])
889        );
890        // Lower suffixes preserved (higher didn't set)
891        assert_eq!(gen.suffixes, Some(vec!["hub".to_string()]));
892    }
893}