ddns_a/config/
validated.rs

1//! Validated configuration after merging CLI and TOML sources.
2//!
3//! This module contains the final, validated configuration that is used
4//! by the application. All validation is performed during construction.
5
6use 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/// Fully validated configuration ready for use by the application.
27///
28/// This struct represents a complete, validated configuration where all
29/// required fields are present and all values have been validated.
30///
31/// # Construction
32///
33/// Use [`ValidatedConfig::from_raw`] to create from CLI args and optional TOML config.
34/// The function validates all inputs and returns errors for invalid configurations.
35#[derive(Debug)]
36pub struct ValidatedConfig {
37    /// IP version to monitor (required)
38    pub ip_version: IpVersion,
39
40    /// Change kind filter (Added/Removed/Both)
41    pub change_kind: ChangeKind,
42
43    /// Webhook URL (required)
44    pub url: Url,
45
46    /// HTTP method for webhook requests
47    pub method: Method,
48
49    /// HTTP headers for webhook requests
50    pub headers: HeaderMap,
51
52    /// Handlebars body template (optional)
53    pub body_template: Option<String>,
54
55    /// Adapter filter configuration
56    pub filter: FilterChain,
57
58    /// Polling interval
59    pub poll_interval: Duration,
60
61    /// Whether to use polling only (no API events)
62    pub poll_only: bool,
63
64    /// Retry policy for failed webhook requests
65    pub retry_policy: RetryPolicy,
66
67    /// Path to state file for detecting changes across restarts.
68    /// If `None`, state persistence is disabled.
69    pub state_file: Option<PathBuf>,
70
71    /// Dry-run mode (log changes without sending webhooks)
72    pub dry_run: bool,
73
74    /// Verbose logging enabled
75    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    /// Creates a validated configuration from CLI arguments and optional TOML config.
106    ///
107    /// CLI arguments take precedence over TOML config values.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if:
112    /// - Required fields are missing (`url`, `ip_version`)
113    /// - URL is invalid
114    /// - Regex patterns are invalid
115    /// - Duration values are zero
116    /// - Header format is invalid
117    pub fn from_raw(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Self, ConfigError> {
118        // Merge and validate IP version (required)
119        let ip_version = Self::resolve_ip_version(cli, toml)?;
120
121        // Merge change kind filter (default: Both)
122        let change_kind = Self::resolve_change_kind(cli, toml)?;
123
124        // Merge and validate URL (required)
125        let url = Self::resolve_url(cli, toml)?;
126
127        // Merge HTTP method (CLI default: POST)
128        let method = Self::resolve_method(cli, toml)?;
129
130        // Merge headers
131        let headers = Self::resolve_headers(cli, toml)?;
132
133        // Merge and validate body template
134        let body_template = Self::resolve_body_template(cli, toml)?;
135
136        // Build adapter filter
137        let filter = Self::build_filter(cli, toml)?;
138
139        // Merge poll interval (CLI default: 60)
140        let poll_interval = Self::resolve_poll_interval(cli, toml)?;
141
142        // Merge poll_only (CLI wins if true)
143        let poll_only = cli.poll_only || toml.is_some_and(|t| t.monitor.poll_only);
144
145        // Build retry policy
146        let retry_policy = Self::build_retry_policy(cli, toml)?;
147
148        // Resolve state file path (CLI takes precedence over TOML)
149        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    /// Loads and merges configuration from CLI and optional config file.
169    ///
170    /// If `cli.config` is set, loads the TOML file from that path.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if:
175    /// - The config file cannot be read or parsed
176    /// - The merged configuration is invalid
177    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        // CLI takes precedence
189        if let Some(version) = cli.ip_version {
190            return Ok(version.into());
191        }
192
193        // Fall back to TOML
194        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        // CLI takes precedence
211        if let Some(kind) = cli.change_kind {
212            return Ok(kind.into());
213        }
214
215        // Fall back to TOML
216        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        // Default to Both
223        Ok(ChangeKind::Both)
224    }
225
226    fn resolve_url(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Url, ConfigError> {
227        // CLI takes precedence
228        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        // Priority: CLI explicit > TOML > default
244        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        // Add TOML headers first (CLI can override)
259        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        // Add CLI headers (override TOML)
268        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        // Handle bearer token (CLI wins, then TOML)
276        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        // Validate Handlebars syntax if template is provided
300        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        // Compile-check only; render with empty context to validate syntax
310        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        // Collect all kinds from CLI and TOML (CLI replaces TOML)
321        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        // Default: exclude loopback UNLESS explicitly included
333        if !include_kinds.contains(&AdapterKind::Loopback) {
334            chain = chain.exclude(KindFilter::new([AdapterKind::Loopback]));
335        }
336
337        // Add kind excludes
338        if !exclude_kinds.is_empty() {
339            chain = chain.exclude(KindFilter::new(exclude_kinds));
340        }
341
342        // Add kind includes
343        if !include_kinds.is_empty() {
344            chain = chain.include(KindFilter::new(include_kinds));
345        }
346
347        // Collect name patterns (CLI replaces TOML)
348        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        // Add name excludes
361        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        // Add name includes
371        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    /// Collects adapter kinds from CLI and/or TOML.
384    ///
385    /// If `cli_replaces` is true, only CLI kinds are used; otherwise TOML kinds are used.
386    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            // Use CLI kinds
395            for kind in cli_kinds {
396                kinds.insert((*kind).into());
397            }
398        } else if let Some(toml_list) = toml_kinds {
399            // Use TOML kinds
400            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        // Priority: CLI explicit > TOML > default
414        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        // Priority: CLI explicit > TOML > default
436        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        // CLI takes precedence
487        if let Some(ref path) = cli.state_file {
488            return Some(expand_tilde(path));
489        }
490
491        // Fall back to TOML
492        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
501/// Expands tilde (`~`) at the start of a path to the user's home directory.
502///
503/// - `~/foo` → `<home>/foo`
504/// - `~` → `<home>`
505/// - Paths not starting with `~` are returned unchanged.
506fn expand_tilde(path: &Path) -> PathBuf {
507    let path_str = path.to_string_lossy();
508
509    // Check if path starts with ~ (tilde)
510    if !path_str.starts_with('~') {
511        return path.to_path_buf();
512    }
513
514    // Get home directory
515    let Some(home) = dirs::home_dir() else {
516        // Cannot determine home directory - return path unchanged
517        tracing::warn!("Cannot expand ~: home directory not found");
518        return path.to_path_buf();
519    };
520
521    // Handle bare ~ or ~/...
522    if path_str == "~" {
523        return home;
524    }
525
526    // Handle ~/path or ~\path (Windows)
527    if path_str.starts_with("~/") || path_str.starts_with("~\\") {
528        return home.join(&path_str[2..]);
529    }
530
531    // ~username style is not supported - return unchanged
532    path.to_path_buf()
533}
534
535/// Writes the default configuration template to a file.
536///
537/// # Errors
538///
539/// Returns an error if the file cannot be written.
540pub 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
548// Helper functions
549
550fn 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    // Try "Key=Value" format first
586    if let Some((name, value)) = s.split_once('=') {
587        return Ok((name.trim().to_string(), value.trim().to_string()));
588    }
589
590    // Try "Key: Value" format
591    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        // Windows-style path separator
638        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        // Tilde not at start should not expand
664        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        // ~username style is not supported
672        let path = Path::new("~otheruser/file");
673        let result = expand_tilde(path);
674        assert_eq!(result, path);
675    }
676}