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
36/// Default configuration values that map to CLI options.
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct DefaultsConfig {
39    /// Default concurrency level
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub concurrency: Option<usize>,
42
43    /// Default TLD preset
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub preset: Option<String>,
46
47    /// Default TLD list (alternative to preset)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub tlds: Option<Vec<String>>,
50
51    /// Default pretty output
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub pretty: Option<bool>,
54
55    /// Default timeout (as string, e.g., "5s", "30s")
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub timeout: Option<String>,
58
59    /// Default WHOIS fallback setting
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub whois_fallback: Option<bool>,
62
63    /// Default bootstrap setting
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub bootstrap: Option<bool>,
66
67    /// Default detailed info setting
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub detailed_info: Option<bool>,
70}
71
72/// Monitoring configuration (placeholder for future features).
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct MonitoringConfig {
75    /// Monitoring interval
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub interval: Option<String>,
78
79    /// Command to run on changes
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub notify_command: Option<String>,
82}
83
84/// Output formatting configuration.
85#[derive(Debug, Clone, Serialize, Deserialize, Default)]
86pub struct OutputConfig {
87    /// Default output format
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub default_format: Option<String>,
90
91    /// Include CSV headers by default
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub csv_headers: Option<bool>,
94
95    /// Pretty-print JSON by default
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub json_pretty: Option<bool>,
98}
99
100/// Configuration discovery and loading functionality.
101pub struct ConfigManager {
102    /// Whether to emit warnings for config issues
103    pub verbose: bool,
104}
105
106impl ConfigManager {
107    /// Create a new configuration manager.
108    pub fn new(verbose: bool) -> Self {
109        Self { verbose }
110    }
111
112    /// Load configuration from a specific file.
113    ///
114    /// # Arguments
115    ///
116    /// * `path` - Path to the configuration file
117    ///
118    /// # Returns
119    ///
120    /// The parsed configuration or an error if parsing fails.
121    pub fn load_file<P: AsRef<Path>>(&self, path: P) -> Result<FileConfig, DomainCheckError> {
122        let path = path.as_ref();
123
124        if !path.exists() {
125            return Err(DomainCheckError::file_error(
126                path.to_string_lossy(),
127                "Configuration file not found",
128            ));
129        }
130
131        let content = fs::read_to_string(path).map_err(|e| {
132            DomainCheckError::file_error(
133                path.to_string_lossy(),
134                format!("Failed to read configuration file: {}", e),
135            )
136        })?;
137
138        let config: FileConfig =
139            toml::from_str(&content).map_err(|e| DomainCheckError::ConfigError {
140                message: format!("Failed to parse TOML configuration: {}", e),
141            })?;
142
143        // Validate the loaded configuration
144        self.validate_config(&config)?;
145
146        Ok(config)
147    }
148
149    /// Discover and load configuration files in precedence order.
150    ///
151    /// Looks for configuration files in standard locations and merges them
152    /// according to precedence rules.
153    ///
154    /// # Returns
155    ///
156    /// Merged configuration from all discovered files.
157    pub fn discover_and_load(&self) -> Result<FileConfig, DomainCheckError> {
158        let mut merged_config = FileConfig::default();
159        let mut loaded_files = Vec::new();
160
161        // 1. Load XDG config (lowest precedence)
162        if let Some(xdg_path) = self.get_xdg_config_path() {
163            if let Ok(config) = self.load_file(&xdg_path) {
164                merged_config = self.merge_configs(merged_config, config);
165                loaded_files.push(xdg_path);
166            }
167        }
168
169        // 2. Load global config
170        if let Some(global_path) = self.get_global_config_path() {
171            if let Ok(config) = self.load_file(&global_path) {
172                merged_config = self.merge_configs(merged_config, config);
173                loaded_files.push(global_path);
174            }
175        }
176
177        // 3. Load local config (highest precedence)
178        if let Some(local_path) = self.get_local_config_path() {
179            if let Ok(config) = self.load_file(&local_path) {
180                merged_config = self.merge_configs(merged_config, config);
181                loaded_files.push(local_path);
182            }
183        }
184
185        // Warn about multiple config files if verbose
186        if self.verbose && loaded_files.len() > 1 {
187            eprintln!("⚠️  Multiple config files found. Using precedence:");
188            for (i, path) in loaded_files.iter().enumerate() {
189                let status = if i == loaded_files.len() - 1 {
190                    "active"
191                } else {
192                    "ignored"
193                };
194                eprintln!("   {} ({})", path.display(), status);
195            }
196        }
197
198        Ok(merged_config)
199    }
200
201    /// Get the local configuration file path.
202    ///
203    /// Looks for configuration files in the current directory.
204    fn get_local_config_path(&self) -> Option<PathBuf> {
205        let candidates = ["./domain-check.toml", "./.domain-check.toml"];
206
207        for candidate in &candidates {
208            let path = Path::new(candidate);
209            if path.exists() {
210                return Some(path.to_path_buf());
211            }
212        }
213
214        None
215    }
216
217    /// Get the global configuration file path.
218    ///
219    /// Looks for configuration files in the user's home directory.
220    fn get_global_config_path(&self) -> Option<PathBuf> {
221        if let Some(home) = env::var_os("HOME") {
222            let candidates = [".domain-check.toml", "domain-check.toml"];
223
224            for candidate in &candidates {
225                let path = Path::new(&home).join(candidate);
226                if path.exists() {
227                    return Some(path);
228                }
229            }
230        }
231
232        None
233    }
234
235    /// Get the XDG configuration file path.
236    ///
237    /// Follows the XDG Base Directory Specification.
238    fn get_xdg_config_path(&self) -> Option<PathBuf> {
239        let config_dir = env::var_os("XDG_CONFIG_HOME")
240            .map(PathBuf::from)
241            .or_else(|| env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
242
243        let path = config_dir.join("domain-check").join("config.toml");
244        if path.exists() {
245            Some(path)
246        } else {
247            None
248        }
249    }
250
251    /// Merge two configurations with proper precedence.
252    ///
253    /// Values from `higher` take precedence over values from `lower`.
254    fn merge_configs(&self, lower: FileConfig, higher: FileConfig) -> FileConfig {
255        FileConfig {
256            defaults: match (lower.defaults, higher.defaults) {
257                (Some(mut lower_defaults), Some(higher_defaults)) => {
258                    // Merge defaults with higher precedence winning
259                    if higher_defaults.concurrency.is_some() {
260                        lower_defaults.concurrency = higher_defaults.concurrency;
261                    }
262                    if higher_defaults.preset.is_some() {
263                        lower_defaults.preset = higher_defaults.preset;
264                    }
265                    if higher_defaults.tlds.is_some() {
266                        lower_defaults.tlds = higher_defaults.tlds;
267                    }
268                    if higher_defaults.pretty.is_some() {
269                        lower_defaults.pretty = higher_defaults.pretty;
270                    }
271                    if higher_defaults.timeout.is_some() {
272                        lower_defaults.timeout = higher_defaults.timeout;
273                    }
274                    if higher_defaults.whois_fallback.is_some() {
275                        lower_defaults.whois_fallback = higher_defaults.whois_fallback;
276                    }
277                    if higher_defaults.bootstrap.is_some() {
278                        lower_defaults.bootstrap = higher_defaults.bootstrap;
279                    }
280                    if higher_defaults.detailed_info.is_some() {
281                        lower_defaults.detailed_info = higher_defaults.detailed_info;
282                    }
283                    Some(lower_defaults)
284                }
285                (None, Some(higher_defaults)) => Some(higher_defaults),
286                (Some(lower_defaults), None) => Some(lower_defaults),
287                (None, None) => None,
288            },
289            custom_presets: match (lower.custom_presets, higher.custom_presets) {
290                (Some(mut lower_presets), Some(higher_presets)) => {
291                    // Merge custom presets, higher precedence wins for conflicts
292                    lower_presets.extend(higher_presets);
293                    Some(lower_presets)
294                }
295                (None, Some(higher_presets)) => Some(higher_presets),
296                (Some(lower_presets), None) => Some(lower_presets),
297                (None, None) => None,
298            },
299            monitoring: higher.monitoring.or(lower.monitoring),
300            output: higher.output.or(lower.output),
301        }
302    }
303
304    /// Validate a configuration for common issues.
305    fn validate_config(&self, config: &FileConfig) -> Result<(), DomainCheckError> {
306        if let Some(defaults) = &config.defaults {
307            // Validate concurrency
308            if let Some(concurrency) = defaults.concurrency {
309                if concurrency == 0 || concurrency > 100 {
310                    return Err(DomainCheckError::ConfigError {
311                        message: "Concurrency must be between 1 and 100".to_string(),
312                    });
313                }
314            }
315
316            // Validate timeout format
317            if let Some(timeout_str) = &defaults.timeout {
318                if parse_timeout_string(timeout_str).is_none() {
319                    return Err(DomainCheckError::ConfigError {
320                        message: format!(
321                            "Invalid timeout format '{}'. Use format like '5s', '30s', '2m'",
322                            timeout_str
323                        ),
324                    });
325                }
326            }
327
328            // Validate that preset and tlds are not both specified
329            if defaults.preset.is_some() && defaults.tlds.is_some() {
330                return Err(DomainCheckError::ConfigError {
331                    message: "Cannot specify both 'preset' and 'tlds' in defaults".to_string(),
332                });
333            }
334        }
335
336        // Validate custom presets
337        if let Some(presets) = &config.custom_presets {
338            for (name, tlds) in presets {
339                if name.is_empty() {
340                    return Err(DomainCheckError::ConfigError {
341                        message: "Custom preset names cannot be empty".to_string(),
342                    });
343                }
344
345                if tlds.is_empty() {
346                    return Err(DomainCheckError::ConfigError {
347                        message: format!("Custom preset '{}' cannot have empty TLD list", name),
348                    });
349                }
350
351                // Basic TLD format validation
352                for tld in tlds {
353                    if tld.is_empty() || tld.contains('.') || tld.contains(' ') {
354                        return Err(DomainCheckError::ConfigError {
355                            message: format!("Invalid TLD '{}' in preset '{}'", tld, name),
356                        });
357                    }
358                }
359            }
360        }
361
362        Ok(())
363    }
364}
365
366/// Environment variable configuration that mirrors CLI options.
367///
368/// This represents configuration values that can be set via DC_* environment variables.
369#[derive(Debug, Clone, Default)]
370pub struct EnvConfig {
371    pub concurrency: Option<usize>,
372    pub preset: Option<String>,
373    pub tlds: Option<Vec<String>>,
374    pub pretty: Option<bool>,
375    pub timeout: Option<String>,
376    pub whois_fallback: Option<bool>,
377    pub bootstrap: Option<bool>,
378    pub detailed_info: Option<bool>,
379    pub json: Option<bool>,
380    pub csv: Option<bool>,
381    pub file: Option<String>,
382    pub config: Option<String>,
383}
384
385/// Load configuration from environment variables.
386///
387/// Parses all DC_* environment variables and returns a structured configuration.
388/// Invalid values are logged as warnings and ignored.
389///
390/// # Arguments
391///
392/// * `verbose` - Whether to log environment variable usage
393///
394/// # Returns
395///
396/// Parsed environment configuration with validated values.
397pub fn load_env_config(verbose: bool) -> EnvConfig {
398    let mut env_config = EnvConfig::default();
399
400    // DC_CONCURRENCY - concurrent domain checks
401    if let Ok(val) = env::var("DC_CONCURRENCY") {
402        match val.parse::<usize>() {
403            Ok(concurrency) if concurrency > 0 && concurrency <= 100 => {
404                env_config.concurrency = Some(concurrency);
405                if verbose {
406                    println!("🔧 Using DC_CONCURRENCY={}", concurrency);
407                }
408            }
409            _ => {
410                if verbose {
411                    eprintln!("⚠️ Invalid DC_CONCURRENCY='{}', must be 1-100", val);
412                }
413            }
414        }
415    }
416
417    // DC_PRESET - TLD preset name
418    if let Ok(preset) = env::var("DC_PRESET") {
419        if !preset.trim().is_empty() {
420            env_config.preset = Some(preset.clone());
421            if verbose {
422                println!("🔧 Using DC_PRESET={}", preset);
423            }
424        }
425    }
426
427    // DC_TLD - comma-separated TLD list
428    if let Ok(tld_str) = env::var("DC_TLD") {
429        let tlds: Vec<String> = tld_str
430            .split(',')
431            .map(|s| s.trim().to_string())
432            .filter(|s| !s.is_empty())
433            .collect();
434        if !tlds.is_empty() {
435            env_config.tlds = Some(tlds);
436            if verbose {
437                println!("🔧 Using DC_TLD={}", tld_str);
438            }
439        }
440    }
441
442    // DC_PRETTY - enable pretty output
443    if let Ok(val) = env::var("DC_PRETTY") {
444        match val.to_lowercase().as_str() {
445            "true" | "1" | "yes" | "on" => {
446                env_config.pretty = Some(true);
447                if verbose {
448                    println!("🔧 Using DC_PRETTY=true");
449                }
450            }
451            "false" | "0" | "no" | "off" => {
452                env_config.pretty = Some(false);
453                if verbose {
454                    println!("🔧 Using DC_PRETTY=false");
455                }
456            }
457            _ => {
458                if verbose {
459                    eprintln!("⚠️ Invalid DC_PRETTY='{}', use true/false", val);
460                }
461            }
462        }
463    }
464
465    // DC_TIMEOUT - timeout setting
466    if let Ok(timeout_str) = env::var("DC_TIMEOUT") {
467        // Validate timeout format
468        if parse_timeout_string(&timeout_str).is_some() {
469            env_config.timeout = Some(timeout_str.clone());
470            if verbose {
471                println!("🔧 Using DC_TIMEOUT={}", timeout_str);
472            }
473        } else if verbose {
474            eprintln!(
475                "⚠️ Invalid DC_TIMEOUT='{}', use format like '5s', '30s', '2m'",
476                timeout_str
477            );
478        }
479    }
480
481    // DC_WHOIS_FALLBACK - enable/disable WHOIS fallback
482    if let Ok(val) = env::var("DC_WHOIS_FALLBACK") {
483        match val.to_lowercase().as_str() {
484            "true" | "1" | "yes" | "on" => {
485                env_config.whois_fallback = Some(true);
486                if verbose {
487                    println!("🔧 Using DC_WHOIS_FALLBACK=true");
488                }
489            }
490            "false" | "0" | "no" | "off" => {
491                env_config.whois_fallback = Some(false);
492                if verbose {
493                    println!("🔧 Using DC_WHOIS_FALLBACK=false");
494                }
495            }
496            _ => {
497                if verbose {
498                    eprintln!("⚠️ Invalid DC_WHOIS_FALLBACK='{}', use true/false", val);
499                }
500            }
501        }
502    }
503
504    // DC_BOOTSTRAP - enable/disable IANA bootstrap
505    if let Ok(val) = env::var("DC_BOOTSTRAP") {
506        match val.to_lowercase().as_str() {
507            "true" | "1" | "yes" | "on" => {
508                env_config.bootstrap = Some(true);
509                if verbose {
510                    println!("🔧 Using DC_BOOTSTRAP=true");
511                }
512            }
513            "false" | "0" | "no" | "off" => {
514                env_config.bootstrap = Some(false);
515                if verbose {
516                    println!("🔧 Using DC_BOOTSTRAP=false");
517                }
518            }
519            _ => {
520                if verbose {
521                    eprintln!("⚠️ Invalid DC_BOOTSTRAP='{}', use true/false", val);
522                }
523            }
524        }
525    }
526
527    // DC_DETAILED_INFO - enable detailed domain info
528    if let Ok(val) = env::var("DC_DETAILED_INFO") {
529        match val.to_lowercase().as_str() {
530            "true" | "1" | "yes" | "on" => {
531                env_config.detailed_info = Some(true);
532                if verbose {
533                    println!("🔧 Using DC_DETAILED_INFO=true");
534                }
535            }
536            "false" | "0" | "no" | "off" => {
537                env_config.detailed_info = Some(false);
538                if verbose {
539                    println!("🔧 Using DC_DETAILED_INFO=false");
540                }
541            }
542            _ => {
543                if verbose {
544                    eprintln!("⚠️ Invalid DC_DETAILED_INFO='{}', use true/false", val);
545                }
546            }
547        }
548    }
549
550    // DC_JSON - enable JSON output
551    if let Ok(val) = env::var("DC_JSON") {
552        match val.to_lowercase().as_str() {
553            "true" | "1" | "yes" | "on" => {
554                env_config.json = Some(true);
555                if verbose {
556                    println!("🔧 Using DC_JSON=true");
557                }
558            }
559            "false" | "0" | "no" | "off" => {
560                env_config.json = Some(false);
561                if verbose {
562                    println!("🔧 Using DC_JSON=false");
563                }
564            }
565            _ => {
566                if verbose {
567                    eprintln!("⚠️ Invalid DC_JSON='{}', use true/false", val);
568                }
569            }
570        }
571    }
572
573    // DC_CSV - enable CSV output
574    if let Ok(val) = env::var("DC_CSV") {
575        match val.to_lowercase().as_str() {
576            "true" | "1" | "yes" | "on" => {
577                env_config.csv = Some(true);
578                if verbose {
579                    println!("🔧 Using DC_CSV=true");
580                }
581            }
582            "false" | "0" | "no" | "off" => {
583                env_config.csv = Some(false);
584                if verbose {
585                    println!("🔧 Using DC_CSV=false");
586                }
587            }
588            _ => {
589                if verbose {
590                    eprintln!("⚠️ Invalid DC_CSV='{}', use true/false", val);
591                }
592            }
593        }
594    }
595
596    // DC_FILE - default domains file
597    if let Ok(file_path) = env::var("DC_FILE") {
598        if !file_path.trim().is_empty() {
599            env_config.file = Some(file_path.clone());
600            if verbose {
601                println!("🔧 Using DC_FILE={}", file_path);
602            }
603        }
604    }
605
606    // DC_CONFIG - default config file
607    if let Ok(config_path) = env::var("DC_CONFIG") {
608        if !config_path.trim().is_empty() {
609            env_config.config = Some(config_path.clone());
610            if verbose {
611                println!("🔧 Using DC_CONFIG={}", config_path);
612            }
613        }
614    }
615
616    env_config
617}
618
619/// Convert EnvConfig to equivalent CLI arguments format for precedence handling.
620///
621/// This allows environment variables to be processed using the same logic as CLI args.
622impl EnvConfig {
623    /// Get the preset value, checking for conflicts with explicit TLD list.
624    pub fn get_effective_preset(&self) -> Option<String> {
625        // If explicit TLDs are set, preset is ignored
626        if self.tlds.is_some() {
627            None
628        } else {
629            self.preset.clone()
630        }
631    }
632
633    /// Get the effective TLD list, preferring explicit TLDs over preset.
634    pub fn get_effective_tlds(&self) -> Option<Vec<String>> {
635        self.tlds.clone()
636    }
637
638    /// Check if output format conflicts exist (JSON and CSV both set).
639    pub fn has_output_format_conflict(&self) -> bool {
640        matches!((self.json, self.csv), (Some(true), Some(true)))
641    }
642}
643
644/// Parse a timeout string like "5s", "30s", "2m" into seconds.
645///
646/// # Arguments
647///
648/// * `timeout_str` - String representation of timeout
649///
650/// # Returns
651///
652/// Number of seconds, or None if parsing fails.
653fn parse_timeout_string(timeout_str: &str) -> Option<u64> {
654    let timeout_str = timeout_str.trim().to_lowercase();
655
656    if timeout_str.ends_with('s') {
657        timeout_str
658            .strip_suffix('s')
659            .and_then(|s| s.parse::<u64>().ok())
660    } else if timeout_str.ends_with('m') {
661        timeout_str
662            .strip_suffix('m')
663            .and_then(|s| s.parse::<u64>().ok())
664            .map(|m| m * 60)
665    } else {
666        // Assume seconds if no unit
667        timeout_str.parse::<u64>().ok()
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use std::io::Write;
675    use tempfile::NamedTempFile;
676
677    #[test]
678    fn test_parse_timeout_string() {
679        assert_eq!(parse_timeout_string("5s"), Some(5));
680        assert_eq!(parse_timeout_string("30s"), Some(30));
681        assert_eq!(parse_timeout_string("2m"), Some(120));
682        assert_eq!(parse_timeout_string("5"), Some(5));
683        assert_eq!(parse_timeout_string("invalid"), None);
684    }
685
686    #[test]
687    fn test_load_valid_config() {
688        let config_content = r#"
689[defaults]
690concurrency = 25
691preset = "startup"
692pretty = true
693
694[custom_presets]
695my_preset = ["com", "org", "io"]
696"#;
697
698        let mut temp_file = NamedTempFile::new().unwrap();
699        temp_file.write_all(config_content.as_bytes()).unwrap();
700        temp_file.flush().unwrap();
701
702        let manager = ConfigManager::new(false);
703        let config = manager.load_file(temp_file.path()).unwrap();
704
705        assert!(config.defaults.is_some());
706        let defaults = config.defaults.unwrap();
707        assert_eq!(defaults.concurrency, Some(25));
708        assert_eq!(defaults.preset, Some("startup".to_string()));
709        assert_eq!(defaults.pretty, Some(true));
710
711        assert!(config.custom_presets.is_some());
712        let presets = config.custom_presets.unwrap();
713        assert_eq!(
714            presets.get("my_preset"),
715            Some(&vec![
716                "com".to_string(),
717                "org".to_string(),
718                "io".to_string()
719            ])
720        );
721    }
722
723    #[test]
724    fn test_invalid_concurrency() {
725        let config_content = r#"
726[defaults]
727concurrency = 0
728"#;
729
730        let mut temp_file = NamedTempFile::new().unwrap();
731        temp_file.write_all(config_content.as_bytes()).unwrap();
732        temp_file.flush().unwrap();
733
734        let manager = ConfigManager::new(false);
735        let result = manager.load_file(temp_file.path());
736        assert!(result.is_err());
737    }
738
739    #[test]
740    fn test_merge_configs() {
741        let manager = ConfigManager::new(false);
742
743        let lower = FileConfig {
744            defaults: Some(DefaultsConfig {
745                concurrency: Some(10),
746                preset: Some("startup".to_string()),
747                pretty: Some(false),
748                ..Default::default()
749            }),
750            ..Default::default()
751        };
752
753        let higher = FileConfig {
754            defaults: Some(DefaultsConfig {
755                concurrency: Some(25),
756                pretty: Some(true),
757                ..Default::default()
758            }),
759            ..Default::default()
760        };
761
762        let merged = manager.merge_configs(lower, higher);
763        let defaults = merged.defaults.unwrap();
764
765        assert_eq!(defaults.concurrency, Some(25)); // Higher wins
766        assert_eq!(defaults.preset, Some("startup".to_string())); // Lower preserved
767        assert_eq!(defaults.pretty, Some(true)); // Higher wins
768    }
769}