1use crate::error::DomainCheckError;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct FileConfig {
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub defaults: Option<DefaultsConfig>,
22
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub custom_presets: Option<HashMap<String, Vec<String>>>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub monitoring: Option<MonitoringConfig>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub output: Option<OutputConfig>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub generation: Option<GenerationConfig>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct DefaultsConfig {
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub concurrency: Option<usize>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub preset: Option<String>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub tlds: Option<Vec<String>>,
54
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub pretty: Option<bool>,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub timeout: Option<String>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub whois_fallback: Option<bool>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub bootstrap: Option<bool>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub detailed_info: Option<bool>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct MonitoringConfig {
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub interval: Option<String>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub notify_command: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct GenerationConfig {
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub prefixes: Option<Vec<String>>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub suffixes: Option<Vec<String>>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct OutputConfig {
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub default_format: Option<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub csv_headers: Option<bool>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub json_pretty: Option<bool>,
114}
115
116pub struct ConfigManager {
118 pub verbose: bool,
120}
121
122impl ConfigManager {
123 pub fn new(verbose: bool) -> Self {
125 Self { verbose }
126 }
127
128 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 self.validate_config(&config)?;
161
162 Ok(config)
163 }
164
165 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 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 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 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 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 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 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 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 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 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 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 fn validate_config(&self, config: &FileConfig) -> Result<(), DomainCheckError> {
336 if let Some(defaults) = &config.defaults {
337 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 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 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 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 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#[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
417pub fn load_env_config(verbose: bool) -> EnvConfig {
430 let mut env_config = EnvConfig::default();
431
432 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 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 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 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 if let Ok(timeout_str) = env::var("DC_TIMEOUT") {
499 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 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 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 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 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 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 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 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 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 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
681impl EnvConfig {
685 pub fn get_effective_preset(&self) -> Option<String> {
687 if self.tlds.is_some() {
689 None
690 } else {
691 self.preset.clone()
692 }
693 }
694
695 pub fn get_effective_tlds(&self) -> Option<Vec<String>> {
697 self.tlds.clone()
698 }
699
700 pub fn has_output_format_conflict(&self) -> bool {
702 matches!((self.json, self.csv), (Some(true), Some(true)))
703 }
704}
705
706fn 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 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 fn write_temp_config(content: &str) -> NamedTempFile {
742 let mut f = NamedTempFile::new().unwrap();
743 f.write_all(content.as_bytes()).unwrap();
744 f.flush().unwrap();
745 f
746 }
747
748 #[test]
751 fn test_parse_timeout_seconds_with_suffix() {
752 assert_eq!(parse_timeout_string("5s"), Some(5));
753 assert_eq!(parse_timeout_string("30s"), Some(30));
754 assert_eq!(parse_timeout_string("0s"), Some(0));
755 assert_eq!(parse_timeout_string("999s"), Some(999));
756 }
757
758 #[test]
759 fn test_parse_timeout_minutes() {
760 assert_eq!(parse_timeout_string("2m"), Some(120));
761 assert_eq!(parse_timeout_string("1m"), Some(60));
762 assert_eq!(parse_timeout_string("0m"), Some(0));
763 }
764
765 #[test]
766 fn test_parse_timeout_bare_number() {
767 assert_eq!(parse_timeout_string("5"), Some(5));
768 assert_eq!(parse_timeout_string("0"), Some(0));
769 assert_eq!(parse_timeout_string("120"), Some(120));
770 }
771
772 #[test]
773 fn test_parse_timeout_whitespace_trimmed() {
774 assert_eq!(parse_timeout_string(" 5s "), Some(5));
775 assert_eq!(parse_timeout_string(" 2m "), Some(120));
776 }
777
778 #[test]
779 fn test_parse_timeout_case_insensitive() {
780 assert_eq!(parse_timeout_string("5S"), Some(5));
781 assert_eq!(parse_timeout_string("2M"), Some(120));
782 }
783
784 #[test]
785 fn test_parse_timeout_invalid() {
786 assert_eq!(parse_timeout_string("invalid"), None);
787 assert_eq!(parse_timeout_string("abc"), None);
788 assert_eq!(parse_timeout_string("s"), None);
789 assert_eq!(parse_timeout_string("m"), None);
790 assert_eq!(parse_timeout_string(""), None);
791 assert_eq!(parse_timeout_string("-5s"), None);
792 }
793
794 #[test]
797 fn test_file_config_default_all_none() {
798 let config = FileConfig::default();
799 assert!(config.defaults.is_none());
800 assert!(config.custom_presets.is_none());
801 assert!(config.monitoring.is_none());
802 assert!(config.output.is_none());
803 assert!(config.generation.is_none());
804 }
805
806 #[test]
807 fn test_defaults_config_default_all_none() {
808 let defaults = DefaultsConfig::default();
809 assert!(defaults.concurrency.is_none());
810 assert!(defaults.preset.is_none());
811 assert!(defaults.tlds.is_none());
812 assert!(defaults.pretty.is_none());
813 assert!(defaults.timeout.is_none());
814 assert!(defaults.whois_fallback.is_none());
815 assert!(defaults.bootstrap.is_none());
816 assert!(defaults.detailed_info.is_none());
817 }
818
819 #[test]
822 fn test_load_valid_config_full() {
823 let f = write_temp_config(
824 r#"
825[defaults]
826concurrency = 25
827preset = "startup"
828pretty = true
829timeout = "10s"
830whois_fallback = false
831bootstrap = true
832detailed_info = true
833
834[custom_presets]
835my_preset = ["com", "org", "io"]
836"#,
837 );
838
839 let manager = ConfigManager::new(false);
840 let config = manager.load_file(f.path()).unwrap();
841
842 let defaults = config.defaults.unwrap();
843 assert_eq!(defaults.concurrency, Some(25));
844 assert_eq!(defaults.preset, Some("startup".to_string()));
845 assert_eq!(defaults.pretty, Some(true));
846 assert_eq!(defaults.timeout, Some("10s".to_string()));
847 assert_eq!(defaults.whois_fallback, Some(false));
848 assert_eq!(defaults.bootstrap, Some(true));
849 assert_eq!(defaults.detailed_info, Some(true));
850
851 let presets = config.custom_presets.unwrap();
852 assert_eq!(
853 presets.get("my_preset"),
854 Some(&vec!["com".into(), "org".into(), "io".into()])
855 );
856 }
857
858 #[test]
859 fn test_load_empty_config() {
860 let f = write_temp_config("");
861 let manager = ConfigManager::new(false);
862 let config = manager.load_file(f.path()).unwrap();
863 assert!(config.defaults.is_none());
864 assert!(config.custom_presets.is_none());
865 }
866
867 #[test]
868 fn test_load_minimal_defaults_only() {
869 let f = write_temp_config(
870 r#"
871[defaults]
872concurrency = 50
873"#,
874 );
875 let manager = ConfigManager::new(false);
876 let config = manager.load_file(f.path()).unwrap();
877 let defaults = config.defaults.unwrap();
878 assert_eq!(defaults.concurrency, Some(50));
879 assert!(defaults.preset.is_none());
880 }
881
882 #[test]
883 fn test_load_nonexistent_file() {
884 let manager = ConfigManager::new(false);
885 let result = manager.load_file("/tmp/nonexistent_domain_check_config_xyz.toml");
886 assert!(result.is_err());
887 let err = result.unwrap_err();
888 assert!(err.to_string().contains("not found"));
889 }
890
891 #[test]
892 fn test_load_invalid_toml() {
893 let f = write_temp_config("this is not [valid toml ===");
894 let manager = ConfigManager::new(false);
895 let result = manager.load_file(f.path());
896 assert!(result.is_err());
897 let err = result.unwrap_err();
898 assert!(err.to_string().contains("TOML"));
899 }
900
901 #[test]
904 fn test_validate_concurrency_zero() {
905 let f = write_temp_config("[defaults]\nconcurrency = 0\n");
906 let manager = ConfigManager::new(false);
907 let result = manager.load_file(f.path());
908 assert!(result.is_err());
909 assert!(result
910 .unwrap_err()
911 .to_string()
912 .contains("between 1 and 100"));
913 }
914
915 #[test]
916 fn test_validate_concurrency_over_100() {
917 let f = write_temp_config("[defaults]\nconcurrency = 101\n");
918 let manager = ConfigManager::new(false);
919 let result = manager.load_file(f.path());
920 assert!(result.is_err());
921 assert!(result
922 .unwrap_err()
923 .to_string()
924 .contains("between 1 and 100"));
925 }
926
927 #[test]
928 fn test_validate_concurrency_boundary_1() {
929 let f = write_temp_config("[defaults]\nconcurrency = 1\n");
930 let manager = ConfigManager::new(false);
931 assert!(manager.load_file(f.path()).is_ok());
932 }
933
934 #[test]
935 fn test_validate_concurrency_boundary_100() {
936 let f = write_temp_config("[defaults]\nconcurrency = 100\n");
937 let manager = ConfigManager::new(false);
938 assert!(manager.load_file(f.path()).is_ok());
939 }
940
941 #[test]
944 fn test_validate_timeout_invalid_format() {
945 let f = write_temp_config("[defaults]\ntimeout = \"abc\"\n");
946 let manager = ConfigManager::new(false);
947 let result = manager.load_file(f.path());
948 assert!(result.is_err());
949 assert!(result.unwrap_err().to_string().contains("Invalid timeout"));
950 }
951
952 #[test]
953 fn test_validate_timeout_valid_seconds() {
954 let f = write_temp_config("[defaults]\ntimeout = \"30s\"\n");
955 let manager = ConfigManager::new(false);
956 assert!(manager.load_file(f.path()).is_ok());
957 }
958
959 #[test]
960 fn test_validate_timeout_valid_minutes() {
961 let f = write_temp_config("[defaults]\ntimeout = \"2m\"\n");
962 let manager = ConfigManager::new(false);
963 assert!(manager.load_file(f.path()).is_ok());
964 }
965
966 #[test]
967 fn test_validate_timeout_bare_number_valid() {
968 let f = write_temp_config("[defaults]\ntimeout = \"10\"\n");
969 let manager = ConfigManager::new(false);
970 assert!(manager.load_file(f.path()).is_ok());
971 }
972
973 #[test]
976 fn test_validate_preset_and_tlds_conflict() {
977 let f = write_temp_config(
978 r#"
979[defaults]
980preset = "startup"
981tlds = ["com", "org"]
982"#,
983 );
984 let manager = ConfigManager::new(false);
985 let result = manager.load_file(f.path());
986 assert!(result.is_err());
987 assert!(result
988 .unwrap_err()
989 .to_string()
990 .contains("Cannot specify both"));
991 }
992
993 #[test]
994 fn test_validate_preset_alone_ok() {
995 let f = write_temp_config("[defaults]\npreset = \"startup\"\n");
996 let manager = ConfigManager::new(false);
997 assert!(manager.load_file(f.path()).is_ok());
998 }
999
1000 #[test]
1001 fn test_validate_tlds_alone_ok() {
1002 let f = write_temp_config("[defaults]\ntlds = [\"com\", \"org\"]\n");
1003 let manager = ConfigManager::new(false);
1004 assert!(manager.load_file(f.path()).is_ok());
1005 }
1006
1007 #[test]
1010 fn test_validate_custom_preset_empty_name() {
1011 let manager = ConfigManager::new(false);
1012 let config = FileConfig {
1013 custom_presets: Some(HashMap::from([("".to_string(), vec!["com".to_string()])])),
1014 ..Default::default()
1015 };
1016 let result = manager.validate_config(&config);
1017 assert!(result.is_err());
1018 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1019 }
1020
1021 #[test]
1022 fn test_validate_custom_preset_empty_tld_list() {
1023 let manager = ConfigManager::new(false);
1024 let config = FileConfig {
1025 custom_presets: Some(HashMap::from([("mypreset".to_string(), vec![])])),
1026 ..Default::default()
1027 };
1028 let result = manager.validate_config(&config);
1029 assert!(result.is_err());
1030 assert!(result.unwrap_err().to_string().contains("empty TLD list"));
1031 }
1032
1033 #[test]
1034 fn test_validate_custom_preset_invalid_tld_with_dot() {
1035 let manager = ConfigManager::new(false);
1036 let config = FileConfig {
1037 custom_presets: Some(HashMap::from([(
1038 "bad".to_string(),
1039 vec!["co.uk".to_string()],
1040 )])),
1041 ..Default::default()
1042 };
1043 let result = manager.validate_config(&config);
1044 assert!(result.is_err());
1045 assert!(result.unwrap_err().to_string().contains("Invalid TLD"));
1046 }
1047
1048 #[test]
1049 fn test_validate_custom_preset_invalid_tld_with_space() {
1050 let manager = ConfigManager::new(false);
1051 let config = FileConfig {
1052 custom_presets: Some(HashMap::from([(
1053 "bad".to_string(),
1054 vec!["c om".to_string()],
1055 )])),
1056 ..Default::default()
1057 };
1058 let result = manager.validate_config(&config);
1059 assert!(result.is_err());
1060 assert!(result.unwrap_err().to_string().contains("Invalid TLD"));
1061 }
1062
1063 #[test]
1064 fn test_validate_custom_preset_invalid_tld_empty_string() {
1065 let manager = ConfigManager::new(false);
1066 let config = FileConfig {
1067 custom_presets: Some(HashMap::from([("bad".to_string(), vec!["".to_string()])])),
1068 ..Default::default()
1069 };
1070 let result = manager.validate_config(&config);
1071 assert!(result.is_err());
1072 assert!(result.unwrap_err().to_string().contains("Invalid TLD"));
1073 }
1074
1075 #[test]
1076 fn test_validate_valid_custom_preset() {
1077 let manager = ConfigManager::new(false);
1078 let config = FileConfig {
1079 custom_presets: Some(HashMap::from([(
1080 "mypreset".to_string(),
1081 vec!["com".to_string(), "org".to_string()],
1082 )])),
1083 ..Default::default()
1084 };
1085 assert!(manager.validate_config(&config).is_ok());
1086 }
1087
1088 #[test]
1091 fn test_merge_defaults_higher_wins() {
1092 let manager = ConfigManager::new(false);
1093 let lower = FileConfig {
1094 defaults: Some(DefaultsConfig {
1095 concurrency: Some(10),
1096 preset: Some("startup".to_string()),
1097 pretty: Some(false),
1098 ..Default::default()
1099 }),
1100 ..Default::default()
1101 };
1102 let higher = FileConfig {
1103 defaults: Some(DefaultsConfig {
1104 concurrency: Some(25),
1105 pretty: Some(true),
1106 ..Default::default()
1107 }),
1108 ..Default::default()
1109 };
1110
1111 let merged = manager.merge_configs(lower, higher);
1112 let defaults = merged.defaults.unwrap();
1113 assert_eq!(defaults.concurrency, Some(25));
1114 assert_eq!(defaults.preset, Some("startup".to_string()));
1115 assert_eq!(defaults.pretty, Some(true));
1116 }
1117
1118 #[test]
1119 fn test_merge_defaults_lower_none() {
1120 let manager = ConfigManager::new(false);
1121 let lower = FileConfig::default();
1122 let higher = FileConfig {
1123 defaults: Some(DefaultsConfig {
1124 concurrency: Some(50),
1125 ..Default::default()
1126 }),
1127 ..Default::default()
1128 };
1129
1130 let merged = manager.merge_configs(lower, higher);
1131 assert_eq!(merged.defaults.unwrap().concurrency, Some(50));
1132 }
1133
1134 #[test]
1135 fn test_merge_defaults_higher_none() {
1136 let manager = ConfigManager::new(false);
1137 let lower = FileConfig {
1138 defaults: Some(DefaultsConfig {
1139 concurrency: Some(10),
1140 ..Default::default()
1141 }),
1142 ..Default::default()
1143 };
1144 let higher = FileConfig::default();
1145
1146 let merged = manager.merge_configs(lower, higher);
1147 assert_eq!(merged.defaults.unwrap().concurrency, Some(10));
1148 }
1149
1150 #[test]
1151 fn test_merge_defaults_both_none() {
1152 let manager = ConfigManager::new(false);
1153 let merged = manager.merge_configs(FileConfig::default(), FileConfig::default());
1154 assert!(merged.defaults.is_none());
1155 }
1156
1157 #[test]
1158 fn test_merge_all_default_fields() {
1159 let manager = ConfigManager::new(false);
1160 let lower = FileConfig {
1161 defaults: Some(DefaultsConfig {
1162 concurrency: Some(10),
1163 preset: Some("lower".to_string()),
1164 tlds: Some(vec!["com".to_string()]),
1165 pretty: Some(false),
1166 timeout: Some("5s".to_string()),
1167 whois_fallback: Some(true),
1168 bootstrap: Some(false),
1169 detailed_info: Some(false),
1170 }),
1171 ..Default::default()
1172 };
1173 let higher = FileConfig {
1174 defaults: Some(DefaultsConfig {
1175 concurrency: Some(50),
1176 preset: Some("higher".to_string()),
1177 tlds: Some(vec!["org".to_string()]),
1178 pretty: Some(true),
1179 timeout: Some("30s".to_string()),
1180 whois_fallback: Some(false),
1181 bootstrap: Some(true),
1182 detailed_info: Some(true),
1183 }),
1184 ..Default::default()
1185 };
1186
1187 let merged = manager.merge_configs(lower, higher);
1188 let d = merged.defaults.unwrap();
1189 assert_eq!(d.concurrency, Some(50));
1190 assert_eq!(d.preset, Some("higher".to_string()));
1191 assert_eq!(d.tlds, Some(vec!["org".to_string()]));
1192 assert_eq!(d.pretty, Some(true));
1193 assert_eq!(d.timeout, Some("30s".to_string()));
1194 assert_eq!(d.whois_fallback, Some(false));
1195 assert_eq!(d.bootstrap, Some(true));
1196 assert_eq!(d.detailed_info, Some(true));
1197 }
1198
1199 #[test]
1200 fn test_merge_custom_presets_combined() {
1201 let manager = ConfigManager::new(false);
1202 let lower = FileConfig {
1203 custom_presets: Some(HashMap::from([
1204 ("a".to_string(), vec!["com".to_string()]),
1205 ("shared".to_string(), vec!["net".to_string()]),
1206 ])),
1207 ..Default::default()
1208 };
1209 let higher = FileConfig {
1210 custom_presets: Some(HashMap::from([
1211 ("b".to_string(), vec!["org".to_string()]),
1212 ("shared".to_string(), vec!["io".to_string()]),
1213 ])),
1214 ..Default::default()
1215 };
1216
1217 let merged = manager.merge_configs(lower, higher);
1218 let presets = merged.custom_presets.unwrap();
1219 assert_eq!(presets.get("a"), Some(&vec!["com".to_string()]));
1220 assert_eq!(presets.get("b"), Some(&vec!["org".to_string()]));
1221 assert_eq!(presets.get("shared"), Some(&vec!["io".to_string()]));
1223 }
1224
1225 #[test]
1226 fn test_merge_custom_presets_lower_none() {
1227 let manager = ConfigManager::new(false);
1228 let lower = FileConfig::default();
1229 let higher = FileConfig {
1230 custom_presets: Some(HashMap::from([("a".to_string(), vec!["com".to_string()])])),
1231 ..Default::default()
1232 };
1233 let merged = manager.merge_configs(lower, higher);
1234 assert!(merged.custom_presets.is_some());
1235 }
1236
1237 #[test]
1238 fn test_merge_custom_presets_higher_none() {
1239 let manager = ConfigManager::new(false);
1240 let lower = FileConfig {
1241 custom_presets: Some(HashMap::from([("a".to_string(), vec!["com".to_string()])])),
1242 ..Default::default()
1243 };
1244 let higher = FileConfig::default();
1245 let merged = manager.merge_configs(lower, higher);
1246 assert!(merged.custom_presets.is_some());
1247 }
1248
1249 #[test]
1250 fn test_merge_monitoring_higher_wins() {
1251 let manager = ConfigManager::new(false);
1252 let lower = FileConfig {
1253 monitoring: Some(MonitoringConfig {
1254 interval: Some("5m".to_string()),
1255 notify_command: None,
1256 }),
1257 ..Default::default()
1258 };
1259 let higher = FileConfig {
1260 monitoring: Some(MonitoringConfig {
1261 interval: Some("10m".to_string()),
1262 notify_command: Some("echo done".to_string()),
1263 }),
1264 ..Default::default()
1265 };
1266 let merged = manager.merge_configs(lower, higher);
1267 let mon = merged.monitoring.unwrap();
1268 assert_eq!(mon.interval, Some("10m".to_string()));
1269 assert_eq!(mon.notify_command, Some("echo done".to_string()));
1270 }
1271
1272 #[test]
1273 fn test_merge_output_higher_wins() {
1274 let manager = ConfigManager::new(false);
1275 let lower = FileConfig {
1276 output: Some(OutputConfig {
1277 default_format: Some("json".to_string()),
1278 csv_headers: Some(true),
1279 json_pretty: None,
1280 }),
1281 ..Default::default()
1282 };
1283 let higher = FileConfig {
1284 output: Some(OutputConfig {
1285 default_format: Some("csv".to_string()),
1286 csv_headers: None,
1287 json_pretty: Some(true),
1288 }),
1289 ..Default::default()
1290 };
1291 let merged = manager.merge_configs(lower, higher);
1292 let out = merged.output.unwrap();
1293 assert_eq!(out.default_format, Some("csv".to_string()));
1295 }
1296
1297 #[test]
1298 fn test_merge_generation_higher_prefixes_win() {
1299 let manager = ConfigManager::new(false);
1300 let lower = FileConfig {
1301 generation: Some(GenerationConfig {
1302 prefixes: Some(vec!["get".to_string()]),
1303 suffixes: Some(vec!["hub".to_string()]),
1304 }),
1305 ..Default::default()
1306 };
1307 let higher = FileConfig {
1308 generation: Some(GenerationConfig {
1309 prefixes: Some(vec!["my".to_string(), "the".to_string()]),
1310 suffixes: None,
1311 }),
1312 ..Default::default()
1313 };
1314
1315 let merged = manager.merge_configs(lower, higher);
1316 let gen = merged.generation.unwrap();
1317 assert_eq!(
1318 gen.prefixes,
1319 Some(vec!["my".to_string(), "the".to_string()])
1320 );
1321 assert_eq!(gen.suffixes, Some(vec!["hub".to_string()]));
1322 }
1323
1324 #[test]
1325 fn test_merge_generation_both_none() {
1326 let manager = ConfigManager::new(false);
1327 let merged = manager.merge_configs(FileConfig::default(), FileConfig::default());
1328 assert!(merged.generation.is_none());
1329 }
1330
1331 #[test]
1332 fn test_merge_generation_lower_none() {
1333 let manager = ConfigManager::new(false);
1334 let higher = FileConfig {
1335 generation: Some(GenerationConfig {
1336 prefixes: Some(vec!["get".to_string()]),
1337 suffixes: None,
1338 }),
1339 ..Default::default()
1340 };
1341 let merged = manager.merge_configs(FileConfig::default(), higher);
1342 assert!(merged.generation.is_some());
1343 }
1344
1345 #[test]
1346 fn test_merge_generation_higher_none() {
1347 let manager = ConfigManager::new(false);
1348 let lower = FileConfig {
1349 generation: Some(GenerationConfig {
1350 prefixes: None,
1351 suffixes: Some(vec!["ly".to_string()]),
1352 }),
1353 ..Default::default()
1354 };
1355 let merged = manager.merge_configs(lower, FileConfig::default());
1356 assert_eq!(
1357 merged.generation.unwrap().suffixes,
1358 Some(vec!["ly".to_string()])
1359 );
1360 }
1361
1362 #[test]
1365 fn test_load_generation_config() {
1366 let f = write_temp_config(
1367 r#"
1368[defaults]
1369concurrency = 20
1370
1371[generation]
1372prefixes = ["get", "my"]
1373suffixes = ["hub", "ly"]
1374"#,
1375 );
1376 let manager = ConfigManager::new(false);
1377 let config = manager.load_file(f.path()).unwrap();
1378 let gen = config.generation.unwrap();
1379 assert_eq!(gen.prefixes, Some(vec!["get".into(), "my".into()]));
1380 assert_eq!(gen.suffixes, Some(vec!["hub".into(), "ly".into()]));
1381 }
1382
1383 #[test]
1384 fn test_load_output_config() {
1385 let f = write_temp_config(
1386 r#"
1387[output]
1388default_format = "json"
1389csv_headers = true
1390json_pretty = false
1391"#,
1392 );
1393 let manager = ConfigManager::new(false);
1394 let config = manager.load_file(f.path()).unwrap();
1395 let out = config.output.unwrap();
1396 assert_eq!(out.default_format, Some("json".to_string()));
1397 assert_eq!(out.csv_headers, Some(true));
1398 assert_eq!(out.json_pretty, Some(false));
1399 }
1400
1401 #[test]
1402 fn test_load_monitoring_config() {
1403 let f = write_temp_config(
1404 r#"
1405[monitoring]
1406interval = "5m"
1407notify_command = "echo done"
1408"#,
1409 );
1410 let manager = ConfigManager::new(false);
1411 let config = manager.load_file(f.path()).unwrap();
1412 let mon = config.monitoring.unwrap();
1413 assert_eq!(mon.interval, Some("5m".to_string()));
1414 assert_eq!(mon.notify_command, Some("echo done".to_string()));
1415 }
1416
1417 #[test]
1420 fn test_file_config_serialization_skip_none() {
1421 let config = FileConfig::default();
1422 let toml_str = toml::to_string(&config).unwrap();
1423 assert!(!toml_str.contains("defaults"));
1425 assert!(!toml_str.contains("custom_presets"));
1426 }
1427
1428 #[test]
1429 fn test_file_config_round_trip() {
1430 let config = FileConfig {
1431 defaults: Some(DefaultsConfig {
1432 concurrency: Some(25),
1433 preset: Some("tech".to_string()),
1434 ..Default::default()
1435 }),
1436 custom_presets: Some(HashMap::from([(
1437 "mine".to_string(),
1438 vec!["com".to_string(), "io".to_string()],
1439 )])),
1440 ..Default::default()
1441 };
1442 let toml_str = toml::to_string(&config).unwrap();
1443 let parsed: FileConfig = toml::from_str(&toml_str).unwrap();
1444 assert_eq!(parsed.defaults.unwrap().concurrency, Some(25));
1445 assert!(parsed.custom_presets.unwrap().contains_key("mine"));
1446 }
1447
1448 #[test]
1451 fn test_env_config_default() {
1452 let env = EnvConfig::default();
1453 assert!(env.concurrency.is_none());
1454 assert!(env.preset.is_none());
1455 assert!(env.tlds.is_none());
1456 assert!(env.pretty.is_none());
1457 assert!(env.timeout.is_none());
1458 assert!(env.json.is_none());
1459 assert!(env.csv.is_none());
1460 assert!(env.file.is_none());
1461 assert!(env.config.is_none());
1462 assert!(env.prefixes.is_none());
1463 assert!(env.suffixes.is_none());
1464 }
1465
1466 #[test]
1467 fn test_get_effective_preset_no_tlds() {
1468 let env = EnvConfig {
1469 preset: Some("startup".to_string()),
1470 tlds: None,
1471 ..Default::default()
1472 };
1473 assert_eq!(env.get_effective_preset(), Some("startup".to_string()));
1474 }
1475
1476 #[test]
1477 fn test_get_effective_preset_with_tlds_returns_none() {
1478 let env = EnvConfig {
1479 preset: Some("startup".to_string()),
1480 tlds: Some(vec!["com".to_string()]),
1481 ..Default::default()
1482 };
1483 assert_eq!(env.get_effective_preset(), None);
1485 }
1486
1487 #[test]
1488 fn test_get_effective_preset_neither_set() {
1489 let env = EnvConfig::default();
1490 assert_eq!(env.get_effective_preset(), None);
1491 }
1492
1493 #[test]
1494 fn test_get_effective_tlds() {
1495 let env = EnvConfig {
1496 tlds: Some(vec!["com".to_string(), "org".to_string()]),
1497 ..Default::default()
1498 };
1499 assert_eq!(
1500 env.get_effective_tlds(),
1501 Some(vec!["com".to_string(), "org".to_string()])
1502 );
1503 }
1504
1505 #[test]
1506 fn test_get_effective_tlds_none() {
1507 let env = EnvConfig::default();
1508 assert_eq!(env.get_effective_tlds(), None);
1509 }
1510
1511 #[test]
1512 fn test_has_output_format_conflict_both_true() {
1513 let env = EnvConfig {
1514 json: Some(true),
1515 csv: Some(true),
1516 ..Default::default()
1517 };
1518 assert!(env.has_output_format_conflict());
1519 }
1520
1521 #[test]
1522 fn test_has_output_format_conflict_one_true() {
1523 let env = EnvConfig {
1524 json: Some(true),
1525 csv: Some(false),
1526 ..Default::default()
1527 };
1528 assert!(!env.has_output_format_conflict());
1529 }
1530
1531 #[test]
1532 fn test_has_output_format_conflict_both_false() {
1533 let env = EnvConfig {
1534 json: Some(false),
1535 csv: Some(false),
1536 ..Default::default()
1537 };
1538 assert!(!env.has_output_format_conflict());
1539 }
1540
1541 #[test]
1542 fn test_has_output_format_conflict_none() {
1543 let env = EnvConfig::default();
1544 assert!(!env.has_output_format_conflict());
1545 }
1546
1547 #[test]
1548 fn test_has_output_format_conflict_one_none_one_true() {
1549 let env = EnvConfig {
1550 json: Some(true),
1551 csv: None,
1552 ..Default::default()
1553 };
1554 assert!(!env.has_output_format_conflict());
1555 }
1556
1557 use std::sync::Mutex;
1563
1564 static ENV_MUTEX: Mutex<()> = Mutex::new(());
1565
1566 fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
1567 let _lock = ENV_MUTEX.lock().unwrap();
1568 for key in &[
1570 "DC_CONCURRENCY",
1571 "DC_PRESET",
1572 "DC_TLD",
1573 "DC_PRETTY",
1574 "DC_TIMEOUT",
1575 "DC_WHOIS_FALLBACK",
1576 "DC_BOOTSTRAP",
1577 "DC_DETAILED_INFO",
1578 "DC_JSON",
1579 "DC_CSV",
1580 "DC_FILE",
1581 "DC_CONFIG",
1582 "DC_PREFIX",
1583 "DC_SUFFIX",
1584 ] {
1585 env::remove_var(key);
1586 }
1587 for (k, v) in vars {
1589 env::set_var(k, v);
1590 }
1591 f();
1592 for (k, _) in vars {
1594 env::remove_var(k);
1595 }
1596 }
1597
1598 #[test]
1599 fn test_load_env_concurrency_valid() {
1600 with_env_vars(&[("DC_CONCURRENCY", "50")], || {
1601 let config = load_env_config(false);
1602 assert_eq!(config.concurrency, Some(50));
1603 });
1604 }
1605
1606 #[test]
1607 fn test_load_env_concurrency_zero_ignored() {
1608 with_env_vars(&[("DC_CONCURRENCY", "0")], || {
1609 let config = load_env_config(false);
1610 assert!(config.concurrency.is_none());
1611 });
1612 }
1613
1614 #[test]
1615 fn test_load_env_concurrency_over_100_ignored() {
1616 with_env_vars(&[("DC_CONCURRENCY", "200")], || {
1617 let config = load_env_config(false);
1618 assert!(config.concurrency.is_none());
1619 });
1620 }
1621
1622 #[test]
1623 fn test_load_env_concurrency_non_numeric_ignored() {
1624 with_env_vars(&[("DC_CONCURRENCY", "abc")], || {
1625 let config = load_env_config(false);
1626 assert!(config.concurrency.is_none());
1627 });
1628 }
1629
1630 #[test]
1631 fn test_load_env_preset() {
1632 with_env_vars(&[("DC_PRESET", "startup")], || {
1633 let config = load_env_config(false);
1634 assert_eq!(config.preset, Some("startup".to_string()));
1635 });
1636 }
1637
1638 #[test]
1639 fn test_load_env_preset_empty_ignored() {
1640 with_env_vars(&[("DC_PRESET", " ")], || {
1641 let config = load_env_config(false);
1642 assert!(config.preset.is_none());
1643 });
1644 }
1645
1646 #[test]
1647 fn test_load_env_tld() {
1648 with_env_vars(&[("DC_TLD", "com,org,io")], || {
1649 let config = load_env_config(false);
1650 assert_eq!(
1651 config.tlds,
1652 Some(vec!["com".into(), "org".into(), "io".into()])
1653 );
1654 });
1655 }
1656
1657 #[test]
1658 fn test_load_env_tld_with_spaces() {
1659 with_env_vars(&[("DC_TLD", " com , org , io ")], || {
1660 let config = load_env_config(false);
1661 assert_eq!(
1662 config.tlds,
1663 Some(vec!["com".into(), "org".into(), "io".into()])
1664 );
1665 });
1666 }
1667
1668 #[test]
1669 fn test_load_env_tld_empty_entries_filtered() {
1670 with_env_vars(&[("DC_TLD", "com,,org,")], || {
1671 let config = load_env_config(false);
1672 assert_eq!(config.tlds, Some(vec!["com".into(), "org".into()]));
1673 });
1674 }
1675
1676 #[test]
1677 fn test_load_env_pretty_true_variants() {
1678 for val in &["true", "1", "yes", "on", "TRUE", "Yes"] {
1679 with_env_vars(&[("DC_PRETTY", val)], || {
1680 let config = load_env_config(false);
1681 assert_eq!(
1682 config.pretty,
1683 Some(true),
1684 "DC_PRETTY={} should be true",
1685 val
1686 );
1687 });
1688 }
1689 }
1690
1691 #[test]
1692 fn test_load_env_pretty_false_variants() {
1693 for val in &["false", "0", "no", "off", "FALSE", "No"] {
1694 with_env_vars(&[("DC_PRETTY", val)], || {
1695 let config = load_env_config(false);
1696 assert_eq!(
1697 config.pretty,
1698 Some(false),
1699 "DC_PRETTY={} should be false",
1700 val
1701 );
1702 });
1703 }
1704 }
1705
1706 #[test]
1707 fn test_load_env_pretty_invalid_ignored() {
1708 with_env_vars(&[("DC_PRETTY", "maybe")], || {
1709 let config = load_env_config(false);
1710 assert!(config.pretty.is_none());
1711 });
1712 }
1713
1714 #[test]
1715 fn test_load_env_timeout_valid() {
1716 with_env_vars(&[("DC_TIMEOUT", "30s")], || {
1717 let config = load_env_config(false);
1718 assert_eq!(config.timeout, Some("30s".to_string()));
1719 });
1720 }
1721
1722 #[test]
1723 fn test_load_env_timeout_invalid_ignored() {
1724 with_env_vars(&[("DC_TIMEOUT", "invalid")], || {
1725 let config = load_env_config(false);
1726 assert!(config.timeout.is_none());
1727 });
1728 }
1729
1730 #[test]
1731 fn test_load_env_boolean_flags() {
1732 with_env_vars(
1733 &[
1734 ("DC_WHOIS_FALLBACK", "true"),
1735 ("DC_BOOTSTRAP", "false"),
1736 ("DC_DETAILED_INFO", "1"),
1737 ("DC_JSON", "yes"),
1738 ("DC_CSV", "off"),
1739 ],
1740 || {
1741 let config = load_env_config(false);
1742 assert_eq!(config.whois_fallback, Some(true));
1743 assert_eq!(config.bootstrap, Some(false));
1744 assert_eq!(config.detailed_info, Some(true));
1745 assert_eq!(config.json, Some(true));
1746 assert_eq!(config.csv, Some(false));
1747 },
1748 );
1749 }
1750
1751 #[test]
1752 fn test_load_env_file() {
1753 with_env_vars(&[("DC_FILE", "/path/to/domains.txt")], || {
1754 let config = load_env_config(false);
1755 assert_eq!(config.file, Some("/path/to/domains.txt".to_string()));
1756 });
1757 }
1758
1759 #[test]
1760 fn test_load_env_file_empty_ignored() {
1761 with_env_vars(&[("DC_FILE", " ")], || {
1762 let config = load_env_config(false);
1763 assert!(config.file.is_none());
1764 });
1765 }
1766
1767 #[test]
1768 fn test_load_env_config_path() {
1769 with_env_vars(&[("DC_CONFIG", "/etc/dc.toml")], || {
1770 let config = load_env_config(false);
1771 assert_eq!(config.config, Some("/etc/dc.toml".to_string()));
1772 });
1773 }
1774
1775 #[test]
1776 fn test_load_env_prefix_suffix() {
1777 with_env_vars(
1778 &[("DC_PREFIX", "get,my,try"), ("DC_SUFFIX", "hub,ly")],
1779 || {
1780 let config = load_env_config(false);
1781 assert_eq!(
1782 config.prefixes,
1783 Some(vec!["get".into(), "my".into(), "try".into()])
1784 );
1785 assert_eq!(config.suffixes, Some(vec!["hub".into(), "ly".into()]));
1786 },
1787 );
1788 }
1789
1790 #[test]
1791 fn test_load_env_no_vars_returns_all_none() {
1792 with_env_vars(&[], || {
1793 let config = load_env_config(false);
1794 assert!(config.concurrency.is_none());
1795 assert!(config.preset.is_none());
1796 assert!(config.tlds.is_none());
1797 assert!(config.pretty.is_none());
1798 assert!(config.timeout.is_none());
1799 assert!(config.whois_fallback.is_none());
1800 assert!(config.bootstrap.is_none());
1801 assert!(config.detailed_info.is_none());
1802 assert!(config.json.is_none());
1803 assert!(config.csv.is_none());
1804 assert!(config.file.is_none());
1805 assert!(config.config.is_none());
1806 assert!(config.prefixes.is_none());
1807 assert!(config.suffixes.is_none());
1808 });
1809 }
1810
1811 #[test]
1814 fn test_config_manager_verbose_flag() {
1815 let manager = ConfigManager::new(true);
1816 assert!(manager.verbose);
1817 let manager = ConfigManager::new(false);
1818 assert!(!manager.verbose);
1819 }
1820}