aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGus Power <gus@infinitesidequests.com>2025-05-21 16:23:55 +0100
committerGus Power <gus@infinitesidequests.com>2025-05-21 16:23:55 +0100
commitd7ce374a1741fdbb5c3aeef1218058a3d1060e88 (patch)
treeac4297e69ce670155fcd3c37c29b085d3707decc /src
parent9ce9c101a10327b1eb6902133173119c3e0f3732 (diff)
config fallback w/ tests. introduced a macro to remove some test boilerplate around Result<T,E> and having return Ok(())
Diffstat (limited to 'src')
-rw-r--r--src/config.rs183
-rw-r--r--src/dyndns_service.rs2
-rw-r--r--src/error.rs17
-rw-r--r--src/ip_service.rs7
-rw-r--r--src/main.rs102
-rw-r--r--src/test_macros.rs17
6 files changed, 237 insertions, 91 deletions
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<P: AsRef<Path>>(path: P) -> AppResult<Config> {
- 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<PathBuf>) -> AppResult<Config> {
+ 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::<WanConfig>(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::<WanConfig>(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::<DnsRecord>(input).unwrap();
+ let dns_record = serde_json::from_value::<DnsRecord>(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<dyn Error>> {
- 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<dyn Error>> {
- 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<dyn Error>> {
- 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<dyn Error>> {
- 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<T> = Result<T, AppError>;
#[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<IpAddr> {
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<PathBuf>,
}
-fn main() {
+impl Args {
+ fn get_config_files(&self) -> Vec<PathBuf> {
+ match self.config_file {
+ Some(ref config_file) => vec![config_file.to_path_buf()],
+ None => DefaultConfigFile::default().paths,
+ }
+ }
+}
+
+struct DefaultConfigFile {
+ paths: Vec<PathBuf>,
+}
+
+impl DefaultConfigFile {
+ fn user_home_config() -> AppResult<Option<PathBuf>> {
+ 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::<OsString>());
+
+ 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,4 +1,17 @@
#[macro_export]
+macro_rules! test {
+ (fn $name:ident() $body:block) => {
+ #[test]
+ fn $name() -> Result<(), Box<dyn std::error::Error>> {
+ (|| -> Result<(), Box<dyn std::error::Error>> {
+ $body;
+ Ok(())
+ })()
+ }
+ };
+}
+
+#[macro_export]
macro_rules! assert_error {
($result:expr, $pattern:pat => $body:block) => {
match $result {
@@ -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);