use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use handlebars::Handlebars;
use http::header::{AUTHORIZATION, HeaderName, HeaderValue};
use http::{HeaderMap, Method};
use url::Url;
use crate::network::IpVersion;
use crate::network::filter::{
CompositeFilter, ExcludeLoopbackFilter, ExcludeVirtualFilter, NameRegexFilter,
};
use crate::webhook::RetryPolicy;
use super::cli::Cli;
use super::defaults;
use super::error::{ConfigError, field};
use super::toml::TomlConfig;
#[derive(Debug)]
pub struct ValidatedConfig {
pub ip_version: IpVersion,
pub url: Url,
pub method: Method,
pub headers: HeaderMap,
pub body_template: Option<String>,
pub filter: CompositeFilter,
pub poll_interval: Duration,
pub poll_only: bool,
pub retry_policy: RetryPolicy,
pub state_file: Option<PathBuf>,
pub dry_run: bool,
pub verbose: bool,
}
impl fmt::Display for ValidatedConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_file_str = self
.state_file
.as_ref()
.map_or_else(|| "none".to_string(), |p| p.display().to_string());
write!(
f,
"Config {{ url: {}, ip_version: {}, method: {}, poll_interval: {}s, poll_only: {}, \
retry: {}x/{}s, state_file: {}, dry_run: {}, filters: {} }}",
self.url,
self.ip_version,
self.method,
self.poll_interval.as_secs(),
self.poll_only,
self.retry_policy.max_attempts,
self.retry_policy.initial_delay.as_secs(),
state_file_str,
self.dry_run,
self.filter.len(),
)
}
}
impl ValidatedConfig {
pub fn from_raw(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Self, ConfigError> {
let ip_version = Self::resolve_ip_version(cli, toml)?;
let url = Self::resolve_url(cli, toml)?;
let method = Self::resolve_method(cli, toml)?;
let headers = Self::resolve_headers(cli, toml)?;
let body_template = Self::resolve_body_template(cli, toml)?;
let filter = Self::build_filter(cli, toml)?;
let poll_interval = Self::resolve_poll_interval(cli, toml)?;
let poll_only = cli.poll_only || toml.is_some_and(|t| t.monitor.poll_only);
let retry_policy = Self::build_retry_policy(cli, toml)?;
let state_file = Self::resolve_state_file(cli, toml);
Ok(Self {
ip_version,
url,
method,
headers,
body_template,
filter,
poll_interval,
poll_only,
retry_policy,
state_file,
dry_run: cli.dry_run,
verbose: cli.verbose,
})
}
pub fn load(cli: &Cli) -> Result<Self, ConfigError> {
let toml = if let Some(ref path) = cli.config {
Some(TomlConfig::load(path)?)
} else {
None
};
Self::from_raw(cli, toml.as_ref())
}
fn resolve_ip_version(cli: &Cli, toml: Option<&TomlConfig>) -> Result<IpVersion, ConfigError> {
if let Some(version) = cli.ip_version {
return Ok(version.into());
}
if let Some(toml) = toml {
if let Some(ref version_str) = toml.webhook.ip_version {
return parse_ip_version(version_str);
}
}
Err(ConfigError::missing(
field::IP_VERSION,
"Use --ip-version or set webhook.ip_version in config file",
))
}
fn resolve_url(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Url, ConfigError> {
let url_str = cli
.url
.as_deref()
.or_else(|| toml.and_then(|t| t.webhook.url.as_deref()))
.ok_or_else(|| {
ConfigError::missing(field::URL, "Use --url or set webhook.url in config file")
})?;
Url::parse(url_str).map_err(|e| ConfigError::InvalidUrl {
url: url_str.to_string(),
reason: e.to_string(),
})
}
fn resolve_method(cli: &Cli, toml: Option<&TomlConfig>) -> Result<Method, ConfigError> {
let method_str = cli
.method
.as_deref()
.or_else(|| toml.and_then(|t| t.webhook.method.as_deref()))
.unwrap_or(defaults::METHOD);
method_str
.parse::<Method>()
.map_err(|_| ConfigError::InvalidMethod(method_str.to_string()))
}
fn resolve_headers(cli: &Cli, toml: Option<&TomlConfig>) -> Result<HeaderMap, ConfigError> {
let mut headers = HeaderMap::new();
if let Some(toml) = toml {
for (name, value) in &toml.webhook.headers {
let header_name = parse_header_name(name)?;
let header_value = parse_header_value(name, value)?;
headers.insert(header_name, header_value);
}
}
for header_str in &cli.headers {
let (name, value) = parse_header_string(header_str)?;
let header_name = parse_header_name(&name)?;
let header_value = parse_header_value(&name, &value)?;
headers.insert(header_name, header_value);
}
let bearer = cli
.bearer
.as_deref()
.or_else(|| toml.and_then(|t| t.webhook.bearer.as_deref()));
if let Some(token) = bearer {
let auth_value = format!("Bearer {token}");
let header_value = parse_header_value("Authorization", &auth_value)?;
headers.insert(AUTHORIZATION, header_value);
}
Ok(headers)
}
fn resolve_body_template(
cli: &Cli,
toml: Option<&TomlConfig>,
) -> Result<Option<String>, ConfigError> {
let template = cli
.body_template
.clone()
.or_else(|| toml.and_then(|t| t.webhook.body_template.clone()));
if let Some(ref tmpl) = template {
Self::validate_template(tmpl)?;
}
Ok(template)
}
fn validate_template(template: &str) -> Result<(), ConfigError> {
let hbs = Handlebars::new();
hbs.render_template(template, &serde_json::json!({}))
.map_err(|e| ConfigError::InvalidTemplate {
reason: e.to_string(),
})?;
Ok(())
}
fn build_filter(cli: &Cli, toml: Option<&TomlConfig>) -> Result<CompositeFilter, ConfigError> {
let mut filter = CompositeFilter::new();
filter = filter.with(ExcludeLoopbackFilter);
let exclude_virtual = cli.exclude_virtual || toml.is_some_and(|t| t.filter.exclude_virtual);
if exclude_virtual {
filter = filter.with(ExcludeVirtualFilter);
}
for pattern in &cli.include_adapters {
let regex_filter =
NameRegexFilter::include(pattern).map_err(|e| ConfigError::InvalidRegex {
pattern: pattern.clone(),
source: e,
})?;
filter = filter.with(regex_filter);
}
if cli.include_adapters.is_empty() {
if let Some(toml) = toml {
for pattern in &toml.filter.include {
let regex_filter = NameRegexFilter::include(pattern).map_err(|e| {
ConfigError::InvalidRegex {
pattern: pattern.clone(),
source: e,
}
})?;
filter = filter.with(regex_filter);
}
}
}
for pattern in &cli.exclude_adapters {
let regex_filter =
NameRegexFilter::exclude(pattern).map_err(|e| ConfigError::InvalidRegex {
pattern: pattern.clone(),
source: e,
})?;
filter = filter.with(regex_filter);
}
if cli.exclude_adapters.is_empty() {
if let Some(toml) = toml {
for pattern in &toml.filter.exclude {
let regex_filter = NameRegexFilter::exclude(pattern).map_err(|e| {
ConfigError::InvalidRegex {
pattern: pattern.clone(),
source: e,
}
})?;
filter = filter.with(regex_filter);
}
}
}
Ok(filter)
}
fn resolve_poll_interval(
cli: &Cli,
toml: Option<&TomlConfig>,
) -> Result<Duration, ConfigError> {
let seconds = cli
.poll_interval
.or_else(|| toml.and_then(|t| t.monitor.poll_interval))
.unwrap_or(defaults::POLL_INTERVAL_SECS);
if seconds == 0 {
return Err(ConfigError::InvalidDuration {
field: "poll_interval",
reason: "must be greater than 0".to_string(),
});
}
Ok(Duration::from_secs(seconds))
}
fn build_retry_policy(
cli: &Cli,
toml: Option<&TomlConfig>,
) -> Result<RetryPolicy, ConfigError> {
let retry = toml.map(|t| &t.retry);
let max_attempts = cli
.retry_max
.or_else(|| retry.and_then(|r| r.max_attempts))
.unwrap_or(defaults::RETRY_MAX_ATTEMPTS);
let initial_delay_secs = cli
.retry_delay
.or_else(|| retry.and_then(|r| r.initial_delay))
.unwrap_or(defaults::RETRY_INITIAL_DELAY_SECS);
let max_delay_secs = retry
.and_then(|r| r.max_delay)
.unwrap_or(defaults::RETRY_MAX_DELAY_SECS);
let multiplier = retry
.and_then(|r| r.multiplier)
.unwrap_or(defaults::RETRY_MULTIPLIER);
if max_attempts == 0 {
return Err(ConfigError::InvalidRetry(
"max_attempts must be greater than 0".to_string(),
));
}
if initial_delay_secs == 0 {
return Err(ConfigError::InvalidRetry(
"initial_delay must be greater than 0".to_string(),
));
}
if multiplier <= 0.0 || !multiplier.is_finite() {
return Err(ConfigError::InvalidRetry(
"multiplier must be a positive finite number".to_string(),
));
}
if max_delay_secs < initial_delay_secs {
return Err(ConfigError::InvalidRetry(format!(
"max_delay ({max_delay_secs}s) must be >= initial_delay ({initial_delay_secs}s)"
)));
}
Ok(RetryPolicy::new()
.with_max_attempts(max_attempts)
.with_initial_delay(Duration::from_secs(initial_delay_secs))
.with_max_delay(Duration::from_secs(max_delay_secs))
.with_multiplier(multiplier))
}
fn resolve_state_file(cli: &Cli, toml: Option<&TomlConfig>) -> Option<PathBuf> {
if let Some(ref path) = cli.state_file {
return Some(expand_tilde(path));
}
toml.and_then(|t| {
t.monitor
.state_file
.as_ref()
.map(|s| expand_tilde(Path::new(s)))
})
}
}
fn expand_tilde(path: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
if !path_str.starts_with('~') {
return path.to_path_buf();
}
let Some(home) = dirs::home_dir() else {
tracing::warn!("Cannot expand ~: home directory not found");
return path.to_path_buf();
};
if path_str == "~" {
return home;
}
if path_str.starts_with("~/") || path_str.starts_with("~\\") {
return home.join(&path_str[2..]);
}
path.to_path_buf()
}
pub fn write_default_config(path: &Path) -> Result<(), ConfigError> {
let template = super::toml::default_config_template();
std::fs::write(path, template).map_err(|e| ConfigError::FileWrite {
path: path.to_path_buf(),
source: e,
})
}
fn parse_ip_version(s: &str) -> Result<IpVersion, ConfigError> {
match s.to_lowercase().as_str() {
"ipv4" | "v4" | "4" => Ok(IpVersion::V4),
"ipv6" | "v6" | "6" => Ok(IpVersion::V6),
"both" | "all" | "dual" => Ok(IpVersion::Both),
_ => Err(ConfigError::InvalidIpVersion {
value: s.to_string(),
}),
}
}
fn parse_header_string(s: &str) -> Result<(String, String), ConfigError> {
if let Some((name, value)) = s.split_once('=') {
return Ok((name.trim().to_string(), value.trim().to_string()));
}
if let Some((name, value)) = s.split_once(':') {
return Ok((name.trim().to_string(), value.trim().to_string()));
}
Err(ConfigError::InvalidHeader {
value: s.to_string(),
})
}
fn parse_header_name(name: &str) -> Result<HeaderName, ConfigError> {
name.parse::<HeaderName>()
.map_err(|e| ConfigError::InvalidHeaderName {
name: name.to_string(),
reason: e.to_string(),
})
}
fn parse_header_value(name: &str, value: &str) -> Result<HeaderValue, ConfigError> {
HeaderValue::from_str(value).map_err(|e| ConfigError::InvalidHeaderValue {
name: name.to_string(),
reason: e.to_string(),
})
}
#[cfg(test)]
mod tilde_tests {
use std::path::Path;
use super::expand_tilde;
#[test]
fn tilde_alone_expands_to_home() {
let result = expand_tilde(Path::new("~"));
let expected = dirs::home_dir().expect("home dir should exist");
assert_eq!(result, expected);
}
#[test]
fn tilde_slash_prefix_expands() {
let result = expand_tilde(Path::new("~/.ddns-a/state.json"));
let home = dirs::home_dir().expect("home dir should exist");
assert_eq!(result, home.join(".ddns-a/state.json"));
}
#[test]
fn tilde_backslash_prefix_expands() {
let result = expand_tilde(Path::new("~\\.ddns-a\\state.json"));
let home = dirs::home_dir().expect("home dir should exist");
assert_eq!(result, home.join(".ddns-a\\state.json"));
}
#[test]
fn absolute_path_unchanged() {
#[cfg(windows)]
let path = Path::new("C:\\Users\\test\\state.json");
#[cfg(not(windows))]
let path = Path::new("/home/test/state.json");
let result = expand_tilde(path);
assert_eq!(result, path);
}
#[test]
fn relative_path_unchanged() {
let path = Path::new("./state.json");
let result = expand_tilde(path);
assert_eq!(result, path);
}
#[test]
fn tilde_in_middle_unchanged() {
let path = Path::new("foo/~/bar");
let result = expand_tilde(path);
assert_eq!(result, path);
}
#[test]
fn tilde_username_style_unchanged() {
let path = Path::new("~otheruser/file");
let result = expand_tilde(path);
assert_eq!(result, path);
}
}