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