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