/*
* Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
*
* This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
* more information.
*/
use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr},
};
use serde::Deserialize;
use tokio::{fs::File, io::AsyncReadExt};
#[derive(Debug)]
pub enum ConfigError {
NonExistentService(String),
}
impl std::error::Error for ConfigError {}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NonExistentService(s) => write!(f, "service '{}' does not exist", s),
}
}
}
#[typetag::serde(tag = "type")]
pub trait ServiceConfig {
fn as_any(&self) -> &dyn std::any::Any;
}
pub fn deserialize_token<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let token_or_path: String = serde::de::Deserialize::deserialize(deserializer)?;
// If the value exists as a path and we can read it, read the file.
let path = std::path::Path::new(&token_or_path);
if path.exists() {
match std::fs::read_to_string(path) {
Ok(token) => return Ok(token.trim().into()),
Err(err) => {
tracing::warn!(msg="token exists as a filesystem path, but could not be read, assuming raw token", %err)
}
}
}
// Assume raw token.
Ok(token_or_path)
}
#[derive(Clone, Deserialize)]
pub struct NotifierConfig {
#[serde(deserialize_with = "deserialize_token")]
pub token: String,
pub services: Vec<String>,
}
fn default_interface() -> IpAddr {
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
}
fn default_port() -> u16 {
3000
}
fn default_hashmap<K, V>() -> HashMap<K, V> {
HashMap::new()
}
#[derive(Deserialize)]
pub struct Config {
#[serde(default = "default_interface")]
pub interface: IpAddr,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_hashmap")]
pub services: HashMap<String, Box<dyn ServiceConfig>>,
#[serde(default = "default_hashmap")]
pub notifiers: HashMap<String, NotifierConfig>,
}
impl Config {
pub async fn from_str(s: &str) -> Result<Self, Box<dyn std::error::Error>> {
let config: Config = toml::from_str(s)?;
// Verify notifiers target services that exist.
for notifier in config.notifiers.values() {
for service in ¬ifier.services {
if !config.services.contains_key(service) {
return Err(Box::new(ConfigError::NonExistentService(service.into())));
}
}
}
Ok(config)
}
pub async fn from_path(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let mut content: String = String::new();
File::open(path).await?.read_to_string(&mut content).await?;
Self::from_str(&content).await
}
}
#[cfg(test)]
mod tests {
use std::net::Ipv6Addr;
use super::*;
#[tokio::test]
async fn defaults() {
let config = Config::from_str("").await.unwrap();
assert_eq!(config.interface, default_interface());
assert_eq!(config.port, default_port());
assert!(config.services.is_empty());
assert!(config.notifiers.is_empty());
}
#[tokio::test]
async fn set_interface_ipv4() {
let config = Config::from_str(
r#"
interface = "0.0.0.0"
"#,
)
.await
.unwrap();
assert_eq!(config.interface, IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
}
#[tokio::test]
async fn set_interface_ipv6() {
let config = Config::from_str(
r#"
interface = "2001:42::69"
"#,
)
.await
.unwrap();
assert_eq!(
config.interface,
IpAddr::V6(Ipv6Addr::new(0x2001, 0x42, 0x0, 0x0, 0x0, 0x0, 0x0, 0x69))
);
}
#[tokio::test]
async fn set_port() {
let config = Config::from_str(
r#"
port = 42
"#,
)
.await
.unwrap();
assert_eq!(config.port, 42);
}
#[tokio::test]
async fn invalid_interface() {
assert!(
Config::from_str(
r#"
interface = "0.0.0.a"
"#
)
.await
.is_err()
);
}
}