1use std::collections::HashSet;
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use handlebars::Handlebars;
12use http::header::{AUTHORIZATION, HeaderName, HeaderValue};
13use http::{HeaderMap, Method};
14use url::Url;
15
16use crate::monitor::ChangeKind;
17use crate::network::filter::{FilterChain, KindFilter, NameRegexFilter};
18use crate::network::{AdapterKind, IpVersion};
19use crate::webhook::RetryPolicy;
20
21use super::cli::{AdapterKindArg, Cli};
22use super::defaults;
23use super::error::{ConfigError, field};
24use super::toml::TomlConfig;
25
26#[derive(Debug)]
36pub struct ValidatedConfig {
37 pub ip_version: IpVersion,
39
40 pub change_kind: ChangeKind,
42
43 pub url: Url,
45
46 pub method: Method,
48
49 pub headers: HeaderMap,
51
52 pub body_template: Option<String>,
54
55 pub filter: FilterChain,
57
58 pub poll_interval: Duration,
60
61 pub poll_only: bool,
63
64 pub retry_policy: RetryPolicy,
66
67 pub state_file: Option<PathBuf>,
70
71 pub dry_run: bool,
73
74 pub verbose: bool,
76}
77
78impl fmt::Display for ValidatedConfig {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 let state_file_str = self
81 .state_file
82 .as_ref()
83 .map_or_else(|| "none".to_string(), |p| p.display().to_string());
84
85 write!(
86 f,
87 "Config {{ url: {}, ip_version: {}, method: {}, poll_interval: {}s, poll_only: {}, \
88 retry: {}x/{}s, state_file: {}, dry_run: {}, filters: inc={}/exc={} }}",
89 self.url,
90 self.ip_version,
91 self.method,
92 self.poll_interval.as_secs(),
93 self.poll_only,
94 self.retry_policy.max_attempts,
95 self.retry_policy.initial_delay.as_secs(),
96 state_file_str,
97 self.dry_run,
98 self.filter.include_count(),
99 self.filter.exclude_count(),
100 )
101 }
102}
103
104impl ValidatedConfig {
105 pub fn from_raw(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Self, ConfigError> {
118 let ip_version = Self::resolve_ip_version(cli, toml)?;
120
121 let change_kind = Self::resolve_change_kind(cli, toml)?;
123
124 let url = Self::resolve_url(cli, toml)?;
126
127 let method = Self::resolve_method(cli, toml)?;
129
130 let headers = Self::resolve_headers(cli, toml)?;
132
133 let body_template = Self::resolve_body_template(cli, toml)?;
135
136 let filter = Self::build_filter(cli, toml)?;
138
139 let poll_interval = Self::resolve_poll_interval(cli, toml)?;
141
142 let poll_only = cli.poll_only || toml.is_some_and(|t| t.monitor.poll_only);
144
145 let retry_policy = Self::build_retry_policy(cli, toml)?;
147
148 let state_file = Self::resolve_state_file(cli, toml);
150
151 Ok(Self {
152 ip_version,
153 change_kind,
154 url,
155 method,
156 headers,
157 body_template,
158 filter,
159 poll_interval,
160 poll_only,
161 retry_policy,
162 state_file,
163 dry_run: cli.dry_run,
164 verbose: cli.verbose,
165 })
166 }
167
168 pub fn load(cli: &Cli) -> Result<Self, ConfigError> {
178 let toml = if let Some(ref path) = cli.config {
179 Some(TomlConfig::load(path)?)
180 } else {
181 None
182 };
183
184 Self::from_raw(cli, toml.as_ref())
185 }
186
187 fn resolve_ip_version(cli: &Cli, toml: Option<&TomlConfig>) -> Result<IpVersion, ConfigError> {
188 if let Some(version) = cli.ip_version {
190 return Ok(version.into());
191 }
192
193 if let Some(toml) = toml {
195 if let Some(ref version_str) = toml.webhook.ip_version {
196 return parse_ip_version(version_str);
197 }
198 }
199
200 Err(ConfigError::missing(
201 field::IP_VERSION,
202 "Use --ip-version or set webhook.ip_version in config file",
203 ))
204 }
205
206 fn resolve_change_kind(
207 cli: &Cli,
208 toml: Option<&TomlConfig>,
209 ) -> Result<ChangeKind, ConfigError> {
210 if let Some(kind) = cli.change_kind {
212 return Ok(kind.into());
213 }
214
215 if let Some(toml) = toml {
217 if let Some(ref kind_str) = toml.monitor.change_kind {
218 return parse_change_kind(kind_str);
219 }
220 }
221
222 Ok(ChangeKind::Both)
224 }
225
226 fn resolve_url(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Url, ConfigError> {
227 let url_str = cli
229 .url
230 .as_deref()
231 .or_else(|| toml.and_then(|t| t.webhook.url.as_deref()))
232 .ok_or_else(|| {
233 ConfigError::missing(field::URL, "Use --url or set webhook.url in config file")
234 })?;
235
236 Url::parse(url_str).map_err(|e| ConfigError::InvalidUrl {
237 url: url_str.to_string(),
238 reason: e.to_string(),
239 })
240 }
241
242 fn resolve_method(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Method, ConfigError> {
243 let method_str = cli
245 .method
246 .as_deref()
247 .or_else(|| toml.and_then(|t| t.webhook.method.as_deref()))
248 .unwrap_or(defaults::METHOD);
249
250 method_str
251 .parse::<Method>()
252 .map_err(|_| ConfigError::InvalidMethod(method_str.to_string()))
253 }
254
255 fn resolve_headers(cli: &Cli, toml: Option<&TomlConfig>) -> Result<HeaderMap, ConfigError> {
256 let mut headers = HeaderMap::new();
257
258 if let Some(toml) = toml {
260 for (name, value) in &toml.webhook.headers {
261 let header_name = parse_header_name(name)?;
262 let header_value = parse_header_value(name, value)?;
263 headers.insert(header_name, header_value);
264 }
265 }
266
267 for header_str in &cli.headers {
269 let (name, value) = parse_header_string(header_str)?;
270 let header_name = parse_header_name(&name)?;
271 let header_value = parse_header_value(&name, &value)?;
272 headers.insert(header_name, header_value);
273 }
274
275 let bearer = cli
277 .bearer
278 .as_deref()
279 .or_else(|| toml.and_then(|t| t.webhook.bearer.as_deref()));
280
281 if let Some(token) = bearer {
282 let auth_value = format!("Bearer {token}");
283 let header_value = parse_header_value("Authorization", &auth_value)?;
284 headers.insert(AUTHORIZATION, header_value);
285 }
286
287 Ok(headers)
288 }
289
290 fn resolve_body_template(
291 cli: &Cli,
292 toml: Option<&TomlConfig>,
293 ) -> Result<Option<String>, ConfigError> {
294 let template = cli
295 .body_template
296 .clone()
297 .or_else(|| toml.and_then(|t| t.webhook.body_template.clone()));
298
299 if let Some(ref tmpl) = template {
301 Self::validate_template(tmpl)?;
302 }
303
304 Ok(template)
305 }
306
307 fn validate_template(template: &str) -> Result<(), ConfigError> {
308 let hbs = Handlebars::new();
309 hbs.render_template(template, &serde_json::json!({}))
311 .map_err(|e| ConfigError::InvalidTemplate {
312 reason: e.to_string(),
313 })?;
314 Ok(())
315 }
316
317 fn build_filter(cli: &Cli, toml: Option<&TomlConfig>) -> Result<FilterChain, ConfigError> {
318 let mut chain = FilterChain::new();
319
320 let include_kinds: HashSet<AdapterKind> = Self::collect_kinds(
322 &cli.include_kinds,
323 toml.map(|t| &t.filter.include_kinds),
324 !cli.include_kinds.is_empty(),
325 )?;
326 let exclude_kinds: HashSet<AdapterKind> = Self::collect_kinds(
327 &cli.exclude_kinds,
328 toml.map(|t| &t.filter.exclude_kinds),
329 !cli.exclude_kinds.is_empty(),
330 )?;
331
332 if !include_kinds.contains(&AdapterKind::Loopback) {
334 chain = chain.exclude(KindFilter::new([AdapterKind::Loopback]));
335 }
336
337 if !exclude_kinds.is_empty() {
339 chain = chain.exclude(KindFilter::new(exclude_kinds));
340 }
341
342 if !include_kinds.is_empty() {
344 chain = chain.include(KindFilter::new(include_kinds));
345 }
346
347 let exclude_patterns = if cli.exclude_adapters.is_empty() {
349 toml.map_or(&[][..], |t| t.filter.exclude.as_slice())
350 } else {
351 cli.exclude_adapters.as_slice()
352 };
353
354 let include_patterns = if cli.include_adapters.is_empty() {
355 toml.map_or(&[][..], |t| t.filter.include.as_slice())
356 } else {
357 cli.include_adapters.as_slice()
358 };
359
360 for pattern in exclude_patterns {
362 let regex_filter =
363 NameRegexFilter::new(pattern).map_err(|e| ConfigError::InvalidRegex {
364 pattern: pattern.clone(),
365 source: e,
366 })?;
367 chain = chain.exclude(regex_filter);
368 }
369
370 for pattern in include_patterns {
372 let regex_filter =
373 NameRegexFilter::new(pattern).map_err(|e| ConfigError::InvalidRegex {
374 pattern: pattern.clone(),
375 source: e,
376 })?;
377 chain = chain.include(regex_filter);
378 }
379
380 Ok(chain)
381 }
382
383 fn collect_kinds(
387 cli_kinds: &[AdapterKindArg],
388 toml_kinds: Option<&Vec<String>>,
389 cli_replaces: bool,
390 ) -> Result<HashSet<AdapterKind>, ConfigError> {
391 let mut kinds = HashSet::new();
392
393 if cli_replaces || toml_kinds.is_none() {
394 for kind in cli_kinds {
396 kinds.insert((*kind).into());
397 }
398 } else if let Some(toml_list) = toml_kinds {
399 for kind_str in toml_list {
401 let kind = parse_adapter_kind(kind_str)?;
402 kinds.insert(kind);
403 }
404 }
405
406 Ok(kinds)
407 }
408
409 fn resolve_poll_interval(
410 cli: &Cli,
411 toml: Option<&TomlConfig>,
412 ) -> Result<Duration, ConfigError> {
413 let seconds = cli
415 .poll_interval
416 .or_else(|| toml.and_then(|t| t.monitor.poll_interval))
417 .unwrap_or(defaults::POLL_INTERVAL_SECS);
418
419 if seconds == 0 {
420 return Err(ConfigError::InvalidDuration {
421 field: "poll_interval",
422 reason: "must be greater than 0".to_string(),
423 });
424 }
425
426 Ok(Duration::from_secs(seconds))
427 }
428
429 fn build_retry_policy(
430 cli: &Cli,
431 toml: Option<&TomlConfig>,
432 ) -> Result<RetryPolicy, ConfigError> {
433 let retry = toml.map(|t| &t.retry);
434
435 let max_attempts = cli
437 .retry_max
438 .or_else(|| retry.and_then(|r| r.max_attempts))
439 .unwrap_or(defaults::RETRY_MAX_ATTEMPTS);
440
441 let initial_delay_secs = cli
442 .retry_delay
443 .or_else(|| retry.and_then(|r| r.initial_delay))
444 .unwrap_or(defaults::RETRY_INITIAL_DELAY_SECS);
445
446 let max_delay_secs = retry
447 .and_then(|r| r.max_delay)
448 .unwrap_or(defaults::RETRY_MAX_DELAY_SECS);
449
450 let multiplier = retry
451 .and_then(|r| r.multiplier)
452 .unwrap_or(defaults::RETRY_MULTIPLIER);
453
454 if max_attempts == 0 {
455 return Err(ConfigError::InvalidRetry(
456 "max_attempts must be greater than 0".to_string(),
457 ));
458 }
459
460 if initial_delay_secs == 0 {
461 return Err(ConfigError::InvalidRetry(
462 "initial_delay must be greater than 0".to_string(),
463 ));
464 }
465
466 if multiplier <= 0.0 || !multiplier.is_finite() {
467 return Err(ConfigError::InvalidRetry(
468 "multiplier must be a positive finite number".to_string(),
469 ));
470 }
471
472 if max_delay_secs < initial_delay_secs {
473 return Err(ConfigError::InvalidRetry(format!(
474 "max_delay ({max_delay_secs}s) must be >= initial_delay ({initial_delay_secs}s)"
475 )));
476 }
477
478 Ok(RetryPolicy::new()
479 .with_max_attempts(max_attempts)
480 .with_initial_delay(Duration::from_secs(initial_delay_secs))
481 .with_max_delay(Duration::from_secs(max_delay_secs))
482 .with_multiplier(multiplier))
483 }
484
485 fn resolve_state_file(cli: &Cli, toml: Option<&TomlConfig>) -> Option<PathBuf> {
486 if let Some(ref path) = cli.state_file {
488 return Some(expand_tilde(path));
489 }
490
491 toml.and_then(|t| {
493 t.monitor
494 .state_file
495 .as_ref()
496 .map(|s| expand_tilde(Path::new(s)))
497 })
498 }
499}
500
501fn expand_tilde(path: &Path) -> PathBuf {
507 let path_str = path.to_string_lossy();
508
509 if !path_str.starts_with('~') {
511 return path.to_path_buf();
512 }
513
514 let Some(home) = dirs::home_dir() else {
516 tracing::warn!("Cannot expand ~: home directory not found");
518 return path.to_path_buf();
519 };
520
521 if path_str == "~" {
523 return home;
524 }
525
526 if path_str.starts_with("~/") || path_str.starts_with("~\\") {
528 return home.join(&path_str[2..]);
529 }
530
531 path.to_path_buf()
533}
534
535pub fn write_default_config(path: &Path) -> Result<(), ConfigError> {
541 let template = super::toml::default_config_template();
542 std::fs::write(path, template).map_err(|e| ConfigError::FileWrite {
543 path: path.to_path_buf(),
544 source: e,
545 })
546}
547
548fn parse_ip_version(s: &str) -> Result<IpVersion, ConfigError> {
551 match s.to_lowercase().as_str() {
552 "ipv4" | "v4" | "4" => Ok(IpVersion::V4),
553 "ipv6" | "v6" | "6" => Ok(IpVersion::V6),
554 "both" | "all" | "dual" => Ok(IpVersion::Both),
555 _ => Err(ConfigError::InvalidIpVersion {
556 value: s.to_string(),
557 }),
558 }
559}
560
561fn parse_adapter_kind(s: &str) -> Result<AdapterKind, ConfigError> {
562 match s.to_lowercase().as_str() {
563 "ethernet" => Ok(AdapterKind::Ethernet),
564 "wireless" => Ok(AdapterKind::Wireless),
565 "virtual" => Ok(AdapterKind::Virtual),
566 "loopback" => Ok(AdapterKind::Loopback),
567 _ => Err(ConfigError::InvalidAdapterKind {
568 value: s.to_string(),
569 }),
570 }
571}
572
573fn parse_change_kind(s: &str) -> Result<ChangeKind, ConfigError> {
574 match s.to_lowercase().as_str() {
575 "added" | "add" => Ok(ChangeKind::Added),
576 "removed" | "remove" => Ok(ChangeKind::Removed),
577 "both" | "all" => Ok(ChangeKind::Both),
578 _ => Err(ConfigError::InvalidChangeKind {
579 value: s.to_string(),
580 }),
581 }
582}
583
584fn parse_header_string(s: &str) -> Result<(String, String), ConfigError> {
585 if let Some((name, value)) = s.split_once('=') {
587 return Ok((name.trim().to_string(), value.trim().to_string()));
588 }
589
590 if let Some((name, value)) = s.split_once(':') {
592 return Ok((name.trim().to_string(), value.trim().to_string()));
593 }
594
595 Err(ConfigError::InvalidHeader {
596 value: s.to_string(),
597 })
598}
599
600fn parse_header_name(name: &str) -> Result<HeaderName, ConfigError> {
601 name.parse::<HeaderName>()
602 .map_err(|e| ConfigError::InvalidHeaderName {
603 name: name.to_string(),
604 reason: e.to_string(),
605 })
606}
607
608fn parse_header_value(name: &str, value: &str) -> Result<HeaderValue, ConfigError> {
609 HeaderValue::from_str(value).map_err(|e| ConfigError::InvalidHeaderValue {
610 name: name.to_string(),
611 reason: e.to_string(),
612 })
613}
614
615#[cfg(test)]
616mod tilde_tests {
617 use std::path::Path;
618
619 use super::expand_tilde;
620
621 #[test]
622 fn tilde_alone_expands_to_home() {
623 let result = expand_tilde(Path::new("~"));
624 let expected = dirs::home_dir().expect("home dir should exist");
625 assert_eq!(result, expected);
626 }
627
628 #[test]
629 fn tilde_slash_prefix_expands() {
630 let result = expand_tilde(Path::new("~/.ddns-a/state.json"));
631 let home = dirs::home_dir().expect("home dir should exist");
632 assert_eq!(result, home.join(".ddns-a/state.json"));
633 }
634
635 #[test]
636 fn tilde_backslash_prefix_expands() {
637 let result = expand_tilde(Path::new("~\\.ddns-a\\state.json"));
639 let home = dirs::home_dir().expect("home dir should exist");
640 assert_eq!(result, home.join(".ddns-a\\state.json"));
641 }
642
643 #[test]
644 fn absolute_path_unchanged() {
645 #[cfg(windows)]
646 let path = Path::new("C:\\Users\\test\\state.json");
647 #[cfg(not(windows))]
648 let path = Path::new("/home/test/state.json");
649
650 let result = expand_tilde(path);
651 assert_eq!(result, path);
652 }
653
654 #[test]
655 fn relative_path_unchanged() {
656 let path = Path::new("./state.json");
657 let result = expand_tilde(path);
658 assert_eq!(result, path);
659 }
660
661 #[test]
662 fn tilde_in_middle_unchanged() {
663 let path = Path::new("foo/~/bar");
665 let result = expand_tilde(path);
666 assert_eq!(result, path);
667 }
668
669 #[test]
670 fn tilde_username_style_unchanged() {
671 let path = Path::new("~otheruser/file");
673 let result = expand_tilde(path);
674 assert_eq!(result, path);
675 }
676}