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
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct DefaultsConfig {
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub concurrency: Option<usize>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub preset: Option<String>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub tlds: Option<Vec<String>>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub pretty: Option<bool>,
54
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub timeout: Option<String>,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub whois_fallback: Option<bool>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub bootstrap: Option<bool>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub detailed_info: Option<bool>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct MonitoringConfig {
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub interval: Option<String>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub notify_command: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
86pub struct OutputConfig {
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub default_format: Option<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub csv_headers: Option<bool>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub json_pretty: Option<bool>,
98}
99
100pub struct ConfigManager {
102 pub verbose: bool,
104}
105
106impl ConfigManager {
107 pub fn new(verbose: bool) -> Self {
109 Self { verbose }
110 }
111
112 pub fn load_file<P: AsRef<Path>>(&self, path: P) -> Result<FileConfig, DomainCheckError> {
122 let path = path.as_ref();
123
124 if !path.exists() {
125 return Err(DomainCheckError::file_error(
126 path.to_string_lossy(),
127 "Configuration file not found",
128 ));
129 }
130
131 let content = fs::read_to_string(path).map_err(|e| {
132 DomainCheckError::file_error(
133 path.to_string_lossy(),
134 format!("Failed to read configuration file: {}", e),
135 )
136 })?;
137
138 let config: FileConfig =
139 toml::from_str(&content).map_err(|e| DomainCheckError::ConfigError {
140 message: format!("Failed to parse TOML configuration: {}", e),
141 })?;
142
143 self.validate_config(&config)?;
145
146 Ok(config)
147 }
148
149 pub fn discover_and_load(&self) -> Result<FileConfig, DomainCheckError> {
158 let mut merged_config = FileConfig::default();
159 let mut loaded_files = Vec::new();
160
161 if let Some(xdg_path) = self.get_xdg_config_path() {
163 if let Ok(config) = self.load_file(&xdg_path) {
164 merged_config = self.merge_configs(merged_config, config);
165 loaded_files.push(xdg_path);
166 }
167 }
168
169 if let Some(global_path) = self.get_global_config_path() {
171 if let Ok(config) = self.load_file(&global_path) {
172 merged_config = self.merge_configs(merged_config, config);
173 loaded_files.push(global_path);
174 }
175 }
176
177 if let Some(local_path) = self.get_local_config_path() {
179 if let Ok(config) = self.load_file(&local_path) {
180 merged_config = self.merge_configs(merged_config, config);
181 loaded_files.push(local_path);
182 }
183 }
184
185 if self.verbose && loaded_files.len() > 1 {
187 eprintln!("⚠️ Multiple config files found. Using precedence:");
188 for (i, path) in loaded_files.iter().enumerate() {
189 let status = if i == loaded_files.len() - 1 {
190 "active"
191 } else {
192 "ignored"
193 };
194 eprintln!(" {} ({})", path.display(), status);
195 }
196 }
197
198 Ok(merged_config)
199 }
200
201 fn get_local_config_path(&self) -> Option<PathBuf> {
205 let candidates = ["./domain-check.toml", "./.domain-check.toml"];
206
207 for candidate in &candidates {
208 let path = Path::new(candidate);
209 if path.exists() {
210 return Some(path.to_path_buf());
211 }
212 }
213
214 None
215 }
216
217 fn get_global_config_path(&self) -> Option<PathBuf> {
221 if let Some(home) = env::var_os("HOME") {
222 let candidates = [".domain-check.toml", "domain-check.toml"];
223
224 for candidate in &candidates {
225 let path = Path::new(&home).join(candidate);
226 if path.exists() {
227 return Some(path);
228 }
229 }
230 }
231
232 None
233 }
234
235 fn get_xdg_config_path(&self) -> Option<PathBuf> {
239 let config_dir = env::var_os("XDG_CONFIG_HOME")
240 .map(PathBuf::from)
241 .or_else(|| env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
242
243 let path = config_dir.join("domain-check").join("config.toml");
244 if path.exists() {
245 Some(path)
246 } else {
247 None
248 }
249 }
250
251 fn merge_configs(&self, lower: FileConfig, higher: FileConfig) -> FileConfig {
255 FileConfig {
256 defaults: match (lower.defaults, higher.defaults) {
257 (Some(mut lower_defaults), Some(higher_defaults)) => {
258 if higher_defaults.concurrency.is_some() {
260 lower_defaults.concurrency = higher_defaults.concurrency;
261 }
262 if higher_defaults.preset.is_some() {
263 lower_defaults.preset = higher_defaults.preset;
264 }
265 if higher_defaults.tlds.is_some() {
266 lower_defaults.tlds = higher_defaults.tlds;
267 }
268 if higher_defaults.pretty.is_some() {
269 lower_defaults.pretty = higher_defaults.pretty;
270 }
271 if higher_defaults.timeout.is_some() {
272 lower_defaults.timeout = higher_defaults.timeout;
273 }
274 if higher_defaults.whois_fallback.is_some() {
275 lower_defaults.whois_fallback = higher_defaults.whois_fallback;
276 }
277 if higher_defaults.bootstrap.is_some() {
278 lower_defaults.bootstrap = higher_defaults.bootstrap;
279 }
280 if higher_defaults.detailed_info.is_some() {
281 lower_defaults.detailed_info = higher_defaults.detailed_info;
282 }
283 Some(lower_defaults)
284 }
285 (None, Some(higher_defaults)) => Some(higher_defaults),
286 (Some(lower_defaults), None) => Some(lower_defaults),
287 (None, None) => None,
288 },
289 custom_presets: match (lower.custom_presets, higher.custom_presets) {
290 (Some(mut lower_presets), Some(higher_presets)) => {
291 lower_presets.extend(higher_presets);
293 Some(lower_presets)
294 }
295 (None, Some(higher_presets)) => Some(higher_presets),
296 (Some(lower_presets), None) => Some(lower_presets),
297 (None, None) => None,
298 },
299 monitoring: higher.monitoring.or(lower.monitoring),
300 output: higher.output.or(lower.output),
301 }
302 }
303
304 fn validate_config(&self, config: &FileConfig) -> Result<(), DomainCheckError> {
306 if let Some(defaults) = &config.defaults {
307 if let Some(concurrency) = defaults.concurrency {
309 if concurrency == 0 || concurrency > 100 {
310 return Err(DomainCheckError::ConfigError {
311 message: "Concurrency must be between 1 and 100".to_string(),
312 });
313 }
314 }
315
316 if let Some(timeout_str) = &defaults.timeout {
318 if parse_timeout_string(timeout_str).is_none() {
319 return Err(DomainCheckError::ConfigError {
320 message: format!(
321 "Invalid timeout format '{}'. Use format like '5s', '30s', '2m'",
322 timeout_str
323 ),
324 });
325 }
326 }
327
328 if defaults.preset.is_some() && defaults.tlds.is_some() {
330 return Err(DomainCheckError::ConfigError {
331 message: "Cannot specify both 'preset' and 'tlds' in defaults".to_string(),
332 });
333 }
334 }
335
336 if let Some(presets) = &config.custom_presets {
338 for (name, tlds) in presets {
339 if name.is_empty() {
340 return Err(DomainCheckError::ConfigError {
341 message: "Custom preset names cannot be empty".to_string(),
342 });
343 }
344
345 if tlds.is_empty() {
346 return Err(DomainCheckError::ConfigError {
347 message: format!("Custom preset '{}' cannot have empty TLD list", name),
348 });
349 }
350
351 for tld in tlds {
353 if tld.is_empty() || tld.contains('.') || tld.contains(' ') {
354 return Err(DomainCheckError::ConfigError {
355 message: format!("Invalid TLD '{}' in preset '{}'", tld, name),
356 });
357 }
358 }
359 }
360 }
361
362 Ok(())
363 }
364}
365
366#[derive(Debug, Clone, Default)]
370pub struct EnvConfig {
371 pub concurrency: Option<usize>,
372 pub preset: Option<String>,
373 pub tlds: Option<Vec<String>>,
374 pub pretty: Option<bool>,
375 pub timeout: Option<String>,
376 pub whois_fallback: Option<bool>,
377 pub bootstrap: Option<bool>,
378 pub detailed_info: Option<bool>,
379 pub json: Option<bool>,
380 pub csv: Option<bool>,
381 pub file: Option<String>,
382 pub config: Option<String>,
383}
384
385pub fn load_env_config(verbose: bool) -> EnvConfig {
398 let mut env_config = EnvConfig::default();
399
400 if let Ok(val) = env::var("DC_CONCURRENCY") {
402 match val.parse::<usize>() {
403 Ok(concurrency) if concurrency > 0 && concurrency <= 100 => {
404 env_config.concurrency = Some(concurrency);
405 if verbose {
406 println!("🔧 Using DC_CONCURRENCY={}", concurrency);
407 }
408 }
409 _ => {
410 if verbose {
411 eprintln!("⚠️ Invalid DC_CONCURRENCY='{}', must be 1-100", val);
412 }
413 }
414 }
415 }
416
417 if let Ok(preset) = env::var("DC_PRESET") {
419 if !preset.trim().is_empty() {
420 env_config.preset = Some(preset.clone());
421 if verbose {
422 println!("🔧 Using DC_PRESET={}", preset);
423 }
424 }
425 }
426
427 if let Ok(tld_str) = env::var("DC_TLD") {
429 let tlds: Vec<String> = tld_str
430 .split(',')
431 .map(|s| s.trim().to_string())
432 .filter(|s| !s.is_empty())
433 .collect();
434 if !tlds.is_empty() {
435 env_config.tlds = Some(tlds);
436 if verbose {
437 println!("🔧 Using DC_TLD={}", tld_str);
438 }
439 }
440 }
441
442 if let Ok(val) = env::var("DC_PRETTY") {
444 match val.to_lowercase().as_str() {
445 "true" | "1" | "yes" | "on" => {
446 env_config.pretty = Some(true);
447 if verbose {
448 println!("🔧 Using DC_PRETTY=true");
449 }
450 }
451 "false" | "0" | "no" | "off" => {
452 env_config.pretty = Some(false);
453 if verbose {
454 println!("🔧 Using DC_PRETTY=false");
455 }
456 }
457 _ => {
458 if verbose {
459 eprintln!("⚠️ Invalid DC_PRETTY='{}', use true/false", val);
460 }
461 }
462 }
463 }
464
465 if let Ok(timeout_str) = env::var("DC_TIMEOUT") {
467 if parse_timeout_string(&timeout_str).is_some() {
469 env_config.timeout = Some(timeout_str.clone());
470 if verbose {
471 println!("🔧 Using DC_TIMEOUT={}", timeout_str);
472 }
473 } else if verbose {
474 eprintln!(
475 "⚠️ Invalid DC_TIMEOUT='{}', use format like '5s', '30s', '2m'",
476 timeout_str
477 );
478 }
479 }
480
481 if let Ok(val) = env::var("DC_WHOIS_FALLBACK") {
483 match val.to_lowercase().as_str() {
484 "true" | "1" | "yes" | "on" => {
485 env_config.whois_fallback = Some(true);
486 if verbose {
487 println!("🔧 Using DC_WHOIS_FALLBACK=true");
488 }
489 }
490 "false" | "0" | "no" | "off" => {
491 env_config.whois_fallback = Some(false);
492 if verbose {
493 println!("🔧 Using DC_WHOIS_FALLBACK=false");
494 }
495 }
496 _ => {
497 if verbose {
498 eprintln!("⚠️ Invalid DC_WHOIS_FALLBACK='{}', use true/false", val);
499 }
500 }
501 }
502 }
503
504 if let Ok(val) = env::var("DC_BOOTSTRAP") {
506 match val.to_lowercase().as_str() {
507 "true" | "1" | "yes" | "on" => {
508 env_config.bootstrap = Some(true);
509 if verbose {
510 println!("🔧 Using DC_BOOTSTRAP=true");
511 }
512 }
513 "false" | "0" | "no" | "off" => {
514 env_config.bootstrap = Some(false);
515 if verbose {
516 println!("🔧 Using DC_BOOTSTRAP=false");
517 }
518 }
519 _ => {
520 if verbose {
521 eprintln!("⚠️ Invalid DC_BOOTSTRAP='{}', use true/false", val);
522 }
523 }
524 }
525 }
526
527 if let Ok(val) = env::var("DC_DETAILED_INFO") {
529 match val.to_lowercase().as_str() {
530 "true" | "1" | "yes" | "on" => {
531 env_config.detailed_info = Some(true);
532 if verbose {
533 println!("🔧 Using DC_DETAILED_INFO=true");
534 }
535 }
536 "false" | "0" | "no" | "off" => {
537 env_config.detailed_info = Some(false);
538 if verbose {
539 println!("🔧 Using DC_DETAILED_INFO=false");
540 }
541 }
542 _ => {
543 if verbose {
544 eprintln!("⚠️ Invalid DC_DETAILED_INFO='{}', use true/false", val);
545 }
546 }
547 }
548 }
549
550 if let Ok(val) = env::var("DC_JSON") {
552 match val.to_lowercase().as_str() {
553 "true" | "1" | "yes" | "on" => {
554 env_config.json = Some(true);
555 if verbose {
556 println!("🔧 Using DC_JSON=true");
557 }
558 }
559 "false" | "0" | "no" | "off" => {
560 env_config.json = Some(false);
561 if verbose {
562 println!("🔧 Using DC_JSON=false");
563 }
564 }
565 _ => {
566 if verbose {
567 eprintln!("⚠️ Invalid DC_JSON='{}', use true/false", val);
568 }
569 }
570 }
571 }
572
573 if let Ok(val) = env::var("DC_CSV") {
575 match val.to_lowercase().as_str() {
576 "true" | "1" | "yes" | "on" => {
577 env_config.csv = Some(true);
578 if verbose {
579 println!("🔧 Using DC_CSV=true");
580 }
581 }
582 "false" | "0" | "no" | "off" => {
583 env_config.csv = Some(false);
584 if verbose {
585 println!("🔧 Using DC_CSV=false");
586 }
587 }
588 _ => {
589 if verbose {
590 eprintln!("⚠️ Invalid DC_CSV='{}', use true/false", val);
591 }
592 }
593 }
594 }
595
596 if let Ok(file_path) = env::var("DC_FILE") {
598 if !file_path.trim().is_empty() {
599 env_config.file = Some(file_path.clone());
600 if verbose {
601 println!("🔧 Using DC_FILE={}", file_path);
602 }
603 }
604 }
605
606 if let Ok(config_path) = env::var("DC_CONFIG") {
608 if !config_path.trim().is_empty() {
609 env_config.config = Some(config_path.clone());
610 if verbose {
611 println!("🔧 Using DC_CONFIG={}", config_path);
612 }
613 }
614 }
615
616 env_config
617}
618
619impl EnvConfig {
623 pub fn get_effective_preset(&self) -> Option<String> {
625 if self.tlds.is_some() {
627 None
628 } else {
629 self.preset.clone()
630 }
631 }
632
633 pub fn get_effective_tlds(&self) -> Option<Vec<String>> {
635 self.tlds.clone()
636 }
637
638 pub fn has_output_format_conflict(&self) -> bool {
640 matches!((self.json, self.csv), (Some(true), Some(true)))
641 }
642}
643
644fn parse_timeout_string(timeout_str: &str) -> Option<u64> {
654 let timeout_str = timeout_str.trim().to_lowercase();
655
656 if timeout_str.ends_with('s') {
657 timeout_str
658 .strip_suffix('s')
659 .and_then(|s| s.parse::<u64>().ok())
660 } else if timeout_str.ends_with('m') {
661 timeout_str
662 .strip_suffix('m')
663 .and_then(|s| s.parse::<u64>().ok())
664 .map(|m| m * 60)
665 } else {
666 timeout_str.parse::<u64>().ok()
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use std::io::Write;
675 use tempfile::NamedTempFile;
676
677 #[test]
678 fn test_parse_timeout_string() {
679 assert_eq!(parse_timeout_string("5s"), Some(5));
680 assert_eq!(parse_timeout_string("30s"), Some(30));
681 assert_eq!(parse_timeout_string("2m"), Some(120));
682 assert_eq!(parse_timeout_string("5"), Some(5));
683 assert_eq!(parse_timeout_string("invalid"), None);
684 }
685
686 #[test]
687 fn test_load_valid_config() {
688 let config_content = r#"
689[defaults]
690concurrency = 25
691preset = "startup"
692pretty = true
693
694[custom_presets]
695my_preset = ["com", "org", "io"]
696"#;
697
698 let mut temp_file = NamedTempFile::new().unwrap();
699 temp_file.write_all(config_content.as_bytes()).unwrap();
700 temp_file.flush().unwrap();
701
702 let manager = ConfigManager::new(false);
703 let config = manager.load_file(temp_file.path()).unwrap();
704
705 assert!(config.defaults.is_some());
706 let defaults = config.defaults.unwrap();
707 assert_eq!(defaults.concurrency, Some(25));
708 assert_eq!(defaults.preset, Some("startup".to_string()));
709 assert_eq!(defaults.pretty, Some(true));
710
711 assert!(config.custom_presets.is_some());
712 let presets = config.custom_presets.unwrap();
713 assert_eq!(
714 presets.get("my_preset"),
715 Some(&vec![
716 "com".to_string(),
717 "org".to_string(),
718 "io".to_string()
719 ])
720 );
721 }
722
723 #[test]
724 fn test_invalid_concurrency() {
725 let config_content = r#"
726[defaults]
727concurrency = 0
728"#;
729
730 let mut temp_file = NamedTempFile::new().unwrap();
731 temp_file.write_all(config_content.as_bytes()).unwrap();
732 temp_file.flush().unwrap();
733
734 let manager = ConfigManager::new(false);
735 let result = manager.load_file(temp_file.path());
736 assert!(result.is_err());
737 }
738
739 #[test]
740 fn test_merge_configs() {
741 let manager = ConfigManager::new(false);
742
743 let lower = FileConfig {
744 defaults: Some(DefaultsConfig {
745 concurrency: Some(10),
746 preset: Some("startup".to_string()),
747 pretty: Some(false),
748 ..Default::default()
749 }),
750 ..Default::default()
751 };
752
753 let higher = FileConfig {
754 defaults: Some(DefaultsConfig {
755 concurrency: Some(25),
756 pretty: Some(true),
757 ..Default::default()
758 }),
759 ..Default::default()
760 };
761
762 let merged = manager.merge_configs(lower, higher);
763 let defaults = merged.defaults.unwrap();
764
765 assert_eq!(defaults.concurrency, Some(25)); assert_eq!(defaults.preset, Some("startup".to_string())); assert_eq!(defaults.pretty, Some(true)); }
769}