From d7ce374a1741fdbb5c3aeef1218058a3d1060e88 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Wed, 21 May 2025 16:23:55 +0100 Subject: config fallback w/ tests. introduced a macro to remove some test boilerplate around Result and having return Ok(()) --- src/config.rs | 183 +++++++++++++++++++++++++++++--------------------- src/dyndns_service.rs | 2 + src/error.rs | 17 ++++- src/ip_service.rs | 7 +- src/main.rs | 102 +++++++++++++++++++++++++--- src/test_macros.rs | 17 ++++- 6 files changed, 237 insertions(+), 91 deletions(-) (limited to 'src') diff --git a/src/config.rs b/src/config.rs index 2c5427e..881759c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,8 +5,9 @@ use fqdn::FQDN; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::{fs, io}; +use log::warn; use strum::Display; #[derive(Debug, Deserialize, Serialize)] @@ -16,17 +17,38 @@ pub struct Config { } impl Config { - pub fn load>(path: P) -> AppResult { - let path = path.as_ref(); - let content = fs::read_to_string(path).map_err(|e| match e.kind() { - io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()), - _ => AppError::IoError(e), - })?; - - serde_json::from_str(&content).map_err(|e| AppError::ConfigParseError { - source: e, - path: path.to_path_buf(), - }) + pub fn load(paths: Vec) -> AppResult { + let mut index = 1; + for path in &paths { + let content = fs::read_to_string(&path); + match content { + Ok(value) => { + match serde_json::from_str(&value) { + Ok(config) => { return Ok(config) }, + Err(error) => { + if index == paths.len() { return Err(AppError::ConfigParseError { + source: error, + path: path.to_path_buf(), + })} + warn!("{}", error); + } + } + } + Err(e) => { + let error = match e.kind() { + io::ErrorKind::NotFound => AppError::ConfigFileNotFound(path.clone()), + _ => AppError::IoError(e), + }; + if index == paths.len() { + return Err(error); + } + warn!("{}", error); + }, + } + index += 1; + } + + Err(AppError::ConfigFileNotProvided) } } @@ -70,91 +92,102 @@ mod tests { use super::*; use crate::dyndns_service::DynDnsProvider::Gandi; use crate::dyndns_service::gandi::GandiConfig; - use crate::{assert_config_parse_error, assert_error, assert_file_not_found, assert_io_error}; + use crate::{ + assert_config_file_not_found, assert_config_parse_error, assert_error, assert_io_error, + test, + }; use serde_json::json; - use std::error::Error; - use std::fs::{File, read_dir}; - use std::io::BufReader; + use std::fs::read_dir; + use std::path::PathBuf; use std::str::FromStr; - #[test] - fn check_minimal_config() { - let input = json!({ - "fqdn": "dyn.domain.com", - "providers": [ { - "type": "GANDI", - "api_key": "SOME-API-KEY", - } ] - }); - - let wan_config = serde_json::from_value::(input).unwrap(); - - assert_eq!( - wan_config.dns_record.fqdn, - FQDN::from_str("dyn.domain.com").unwrap() - ); - assert_eq!(wan_config.interface, None); - - assert_eq!(wan_config.providers.len(), 1); - let expected = GandiConfig::new("SOME-API-KEY".to_string()); - let actual = wan_config.providers.get(0).unwrap(); - assert_eq!(&Gandi(expected), actual); + test! { + fn check_minimal_config() { + let input = json!({ + "fqdn": "dyn.domain.com", + "providers": [ { + "type": "GANDI", + "api_key": "SOME-API-KEY", + } ] + }); + + let wan_config = serde_json::from_value::(input)?; + + assert_eq!( + wan_config.dns_record.fqdn, + FQDN::from_str("dyn.domain.com")? + ); + assert_eq!(wan_config.interface, None); + + assert_eq!(wan_config.providers.len(), 1); + let expected = GandiConfig::new("SOME-API-KEY".to_string()); + let actual = wan_config.providers.get(0).unwrap(); + assert_eq!(&Gandi(expected), actual); + } } - #[test] - fn check_defaults_on_dns_record_deserialization() { - let input = json!({ - "fqdn": "dyn.mydomain.com", - }); + test! { + fn check_defaults_on_dns_record_deserialization() { + let input = json!({ + "fqdn": "dyn.mydomain.com", + }); - let dns_record = serde_json::from_value::(input).unwrap(); + let dns_record = serde_json::from_value::(input)?; - assert_eq!(dns_record.record_type, default_record_type()); - assert_eq!(dns_record.ttl, default_ttl()); + assert_eq!(dns_record.record_type, default_record_type()); + assert_eq!(dns_record.ttl, default_ttl()); - assert_eq!(dns_record.fqdn, FQDN::from_str("dyn.mydomain.com").unwrap()); + assert_eq!(dns_record.fqdn, FQDN::from_str("dyn.mydomain.com")?); + } } - #[test] - fn check_file_configs() -> Result<(), Box> { - let path = Path::new("test"); - for entry in read_dir(path)? { - let entry = entry?; - let test_file_path = entry.path(); - if test_file_path.extension().unwrap_or_default() != "json" { - continue; + test! { + fn check_test_file_configs() { + let path = Path::new("test"); + for entry in read_dir(path)? { + let entry = entry?; + let test_file_path = entry.path(); + if test_file_path.extension().unwrap_or_default() != "json" { + continue; + } + + Config::load(vec![test_file_path])?; } + } + } - let file = File::open(test_file_path)?; - let reader = BufReader::new(file); + test! { + fn check_chooses_fallback_config() { + let configs = vec![PathBuf::from_str("test/unknown.json")?, + PathBuf::from_str("test/also-unknown.json")?, + PathBuf::from_str("test/config.bork")?, + PathBuf::from_str("test/noop.json")?]; - let actual: Config = serde_json::from_reader(reader)?; - assert_eq!(actual.networks.len(), 2); + Config::load(configs)?; } - Ok(()) } - #[test] - fn check_missing_config() -> Result<(), Box> { - let path = Path::new("test/unknown.yaml"); + test! { + fn missing_config() { + let path = Path::new("test/unknown.yaml"); - assert_file_not_found!(Config::load(path), path); - Ok(()) + assert_config_file_not_found!(Config::load(vec![path.to_path_buf()]), path); + } } - #[test] - fn check_broken_config() -> Result<(), Box> { - let path = Path::new("test/config.bork"); + test! { + fn broken_config() { + let path = Path::new("test/config.bork"); - assert_config_parse_error!(Config::load(path), path); - Ok(()) + assert_config_parse_error!(Config::load(vec![path.to_path_buf()]), path); + } } - #[test] - fn check_inaccessible_config() -> Result<(), Box> { - let path = Path::new("/root/secure-config.json"); + test! { + fn inaccessible_config() { + let path = Path::new("/root/secure-config.json"); - assert_io_error!(Config::load(path)); - Ok(()) + assert_io_error!(Config::load(vec![path.to_path_buf()])); + } } } diff --git a/src/dyndns_service.rs b/src/dyndns_service.rs index a80fe4b..3f34a1b 100644 --- a/src/dyndns_service.rs +++ b/src/dyndns_service.rs @@ -10,6 +10,8 @@ use std::error::Error; pub enum DynDnsProvider { #[serde(rename = "GANDI")] Gandi(GandiConfig), + #[serde(rename = "NOOP")] + Noop, } pub struct DynDnsService {} diff --git a/src/error.rs b/src/error.rs index 3e05d2a..429aa58 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use homedir::GetHomeError; use reqwest::{Error as ReqwestError, Url}; use serde_json::Error as JsonError; use std::fmt; @@ -7,17 +8,24 @@ use std::path::PathBuf; pub type AppResult = Result; #[derive(Debug)] pub enum AppError { - FileNotFound(PathBuf), - IoError(io::Error), + ConfigFileNotFound(PathBuf), + ConfigFileNotProvided, ConfigParseError { source: JsonError, path: PathBuf }, + IoError(io::Error), RequestFailed { url: Url, source: ReqwestError }, InvalidResponse { url: Url, reason: String }, + UnableToGetHomeDirectory(GetHomeError), } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::FileNotFound(path) => write!(f, "File not found: {}", path.display()), + Self::ConfigFileNotFound(path) => { + write!(f, "Config file not found: {}", path.display()) + } + Self::ConfigFileNotProvided => { + write!(f, "Config file not provided") + } Self::IoError(err) => write!(f, "I/O error: {}", err), Self::ConfigParseError { path, .. } => { write!(f, "Failed to parse config at {}", path.display()) @@ -26,6 +34,9 @@ impl fmt::Display for AppError { Self::InvalidResponse { url, reason } => { write!(f, "Invalid response from {}: {}", url, reason) } + Self::UnableToGetHomeDirectory(err) => { + write!(f, "Failed to get home directory: {}", err) + } } } } diff --git a/src/ip_service.rs b/src/ip_service.rs index f1e3078..3d869db 100644 --- a/src/ip_service.rs +++ b/src/ip_service.rs @@ -1,11 +1,11 @@ use crate::error::{AppError, AppResult}; -use crate::ip_service::IpServiceProvider::IdentMe; +use crate::ip_service::IpServiceProvider::{IdentMe, Noop}; use http::StatusCode; use reqwest::{Client, Url}; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr}; use std::str::FromStr; #[derive(Debug, Deserialize, PartialEq, Serialize)] @@ -13,12 +13,15 @@ use std::str::FromStr; pub enum IpServiceProvider { #[serde(rename = "IDENTME")] IdentMe(IdentMeConfig), + #[serde(rename = "NOOP")] + Noop, } impl IpService for IpServiceProvider { async fn resolve(&self, client: &Client) -> AppResult { match self { IdentMe(ident_me) => ident_me.resolve(client).await, + Noop => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), } } } diff --git a/src/main.rs b/src/main.rs index 57f4c7d..66fbdee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,10 @@ use crate::config::Config; -use crate::error::AppResult; +use crate::error::{AppError, AppResult}; +use crate::ip_service::IpService; use clap::Parser; +use homedir::my_home; +use log::info; +use reqwest::ClientBuilder; use std::path::PathBuf; use std::process::exit; @@ -13,17 +17,54 @@ mod ip_service; mod test_macros; #[derive(Parser, Debug)] -#[command(version, about, long_about = None)] +#[command(ignore_errors(true), version, about, long_about = None)] struct Args { - #[arg(short, long, default_value = "~/.config/multiwan-dyndns/config.json")] - config_file: PathBuf, + #[arg(short, long)] + config_file: Option, } -fn main() { +impl Args { + fn get_config_files(&self) -> Vec { + match self.config_file { + Some(ref config_file) => vec![config_file.to_path_buf()], + None => DefaultConfigFile::default().paths, + } + } +} + +struct DefaultConfigFile { + paths: Vec, +} + +impl DefaultConfigFile { + fn user_home_config() -> AppResult> { + let home = my_home().map_err(AppError::UnableToGetHomeDirectory)?; + match home { + None => Ok(None), + Some(mut path) => { + path.push(".config/multiwan-dyndns/config.json"); + Ok(Some(path)) + } + } + } +} +impl Default for DefaultConfigFile { + fn default() -> Self { + let mut paths = Vec::new(); + paths.push(PathBuf::from("/etc/multiwan-dyndns.json")); + if let Some(user_home) = Self::user_home_config().unwrap() { + paths.push(user_home); + } + Self { paths } + } +} + +#[tokio::main] +async fn main() { env_logger::init(); let args = Args::parse(); - match run(args) { + match run(args).await { Ok(_) => exit(0), Err(e) => { log::error!("{}", e); @@ -32,10 +73,12 @@ fn main() { } } -fn run(args: Args) -> AppResult<()> { - let config = Config::load(&args.config_file)?; +async fn run(args: Args) -> AppResult<()> { + let config = Config::load(args.get_config_files())?; + let client = ClientBuilder::new().build()?; for network in config.networks { - // let ip = IpService::resolve(&network.ip_service)?; + let ip = network.ip_service.resolve(&client).await?; + info!("{:?}: {}", network.interface, ip); // for dyndns in network.providers { // DynDnsService::register(&dyndns, &ip)?; // } @@ -43,3 +86,44 @@ fn run(args: Args) -> AppResult<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use homedir::my_home; + use std::ffi::OsString; + use std::iter; + use std::path::Path; + + macro_rules! assert_contains_config { + ($config:expr, $expected:expr) => { + assert!( + $config.contains(&$expected.to_path_buf()), + "Expected {} path {:?}\nActual paths: {:?}", + stringify!($expected), + $expected, + $config + ); + }; + } + + test! { + fn verify_default_config_path_is_in_home_directory() { + let args = Args::parse_from(iter::empty::()); + + let global_config = Path::new("/etc/multiwan-dyndns.json"); + let user_config_path = format!("{}/.config/multiwan-dyndns/config.json", my_home()?.unwrap().to_string_lossy()); + let user_config = Path::new(&user_config_path); + + let actual = args.get_config_files(); + assert_contains_config!(actual, user_config); + assert_contains_config!(actual, global_config); + } + } + + #[tokio::test] + async fn verify_it_actually_runs() { + let args = vec!["multiwan-dyndns", "--config-file", "test/noop.json"]; + run(Args::parse_from(args)).await.expect("app didn't run!"); + } +} diff --git a/src/test_macros.rs b/src/test_macros.rs index 9f3ff2b..e1998cd 100644 --- a/src/test_macros.rs +++ b/src/test_macros.rs @@ -1,3 +1,16 @@ +#[macro_export] +macro_rules! test { + (fn $name:ident() $body:block) => { + #[test] + fn $name() -> Result<(), Box> { + (|| -> Result<(), Box> { + $body; + Ok(()) + })() + } + }; +} + #[macro_export] macro_rules! assert_error { ($result:expr, $pattern:pat => $body:block) => { @@ -12,9 +25,9 @@ macro_rules! assert_error { } #[macro_export] -macro_rules! assert_file_not_found { +macro_rules! assert_config_file_not_found { ($result:expr, $expected_path:expr) => { - assert_error!($result, AppError::FileNotFound(path) => { + assert_error!($result, AppError::ConfigFileNotFound(path) => { assert_eq!(path, $expected_path, "Expected file not found for path {:?}, but got path {:?}", $expected_path, path); -- cgit v1.2.3