use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug, Clone)]
pub struct AgentConfig {
pub agent: AgentSection,
pub log: LogSection,
}
#[derive(Deserialize, Debug, Clone)]
pub struct AgentSection {
pub id: String,
pub nats_url: String,
#[serde(default)]
pub groups: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LogSection {
pub path: String,
pub level: String,
#[serde(default = "default_keep_days")]
pub keep_days: usize,
}
fn default_keep_days() -> usize {
14
}
#[derive(Deserialize, Debug, Clone)]
pub struct BackendConfig {
pub server: ServerSection,
pub nats: NatsSection,
pub db: DbSection,
pub log: LogSection,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MailSection {
pub host: String,
pub port: u16,
#[serde(default)]
pub encryption: MailEncryption,
pub from: String,
#[serde(default)]
pub username: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MailEncryption {
#[default]
Starttls,
Tls,
None,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ServerSection {
pub bind: String,
#[serde(default)]
pub public_url: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct NatsSection {
pub url: String,
#[serde(default)]
pub monitor_url: Option<String>,
}
const DEFAULT_MONITOR_PORT: u16 = 8222;
impl NatsSection {
pub fn resolved_monitor_url(&self) -> String {
if let Some(u) = self.monitor_url.as_deref().map(str::trim)
&& !u.is_empty()
{
let u = u.trim_end_matches('/');
return if u.contains("://") {
u.to_string()
} else {
format!("http://{u}")
};
}
let host = monitor_host_from_client_url(&self.url);
format!("http://{host}:{DEFAULT_MONITOR_PORT}")
}
}
fn monitor_host_from_client_url(url: &str) -> String {
let after_scheme = url.split_once("://").map_or(url, |(_scheme, rest)| rest);
let authority = match after_scheme.rsplit_once('@') {
Some((_creds, host)) => host,
None => after_scheme,
};
let authority = authority
.split(['/', '?', '#'])
.next()
.unwrap_or(authority)
.trim();
let host = if let Some(end) = authority.find(']') {
&authority[..=end]
} else {
authority.split(':').next().unwrap_or(authority)
};
if host.is_empty() {
"127.0.0.1".to_string()
} else {
host.to_string()
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct DbSection {
pub sqlite_path: String,
}
fn load_typed<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
let mut engine = teravars::Engine::new();
let ctx = teravars::system_context();
let paths: Vec<PathBuf> = vec![path.to_path_buf()];
let merged = teravars::load_merged(&paths, &mut engine, &ctx)
.with_context(|| format!("teravars load_merged: {path:?}"))?;
let cfg: T = toml::Value::Table(merged.config)
.try_into()
.with_context(|| format!("decode config from {path:?}"))?;
Ok(cfg)
}
pub fn load_agent_config(path: &Path) -> Result<AgentConfig> {
load_typed(path)
}
pub fn load_backend_config(path: &Path) -> Result<BackendConfig> {
load_typed(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn nats(url: &str, monitor: Option<&str>) -> NatsSection {
NatsSection {
url: url.to_string(),
monitor_url: monitor.map(str::to_string),
}
}
#[test]
fn the_monitor_url_derives_from_the_client_url_host_only() {
for (client, want) in [
("nats://127.0.0.1:4222", "http://127.0.0.1:8222"),
(
"nats://nats.example.com:4222",
"http://nats.example.com:8222",
),
("broker-01", "http://broker-01:8222"),
("nats://user:p@ss@10.0.0.5:4222", "http://10.0.0.5:8222"),
(
"wss://kanade.example.com:443/nats",
"http://kanade.example.com:8222",
),
("nats://[::1]:4222", "http://[::1]:8222"),
] {
assert_eq!(
nats(client, None).resolved_monitor_url(),
want,
"deriving from {client}",
);
}
}
#[test]
fn an_explicit_monitor_url_wins_and_is_taken_verbatim() {
assert_eq!(
nats("nats://127.0.0.1:4222", Some("http://10.0.0.9:9999")).resolved_monitor_url(),
"http://10.0.0.9:9999",
);
assert_eq!(
nats("nats://127.0.0.1:4222", Some("http://10.0.0.9:9999/")).resolved_monitor_url(),
"http://10.0.0.9:9999",
);
assert_eq!(
nats("nats://127.0.0.1:4222", Some(" ")).resolved_monitor_url(),
"http://127.0.0.1:8222",
);
assert_eq!(
nats("nats://127.0.0.1:4222", Some("10.0.0.9:8222")).resolved_monitor_url(),
"http://10.0.0.9:8222",
);
assert_eq!(
nats("nats://127.0.0.1:4222", Some("https://mon.example.com")).resolved_monitor_url(),
"https://mon.example.com",
);
}
#[test]
fn agent_dev_toml_renders_pc_id_from_env_or_system_host() {
let cfg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("configs")
.join("agent.dev.toml");
unsafe {
std::env::set_var("KANADE_DEV_AGENT_ID", "dev-pc-render-test");
}
let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env set)");
assert_eq!(cfg.agent.id, "dev-pc-render-test");
assert!(
cfg.log.path.contains("dev-pc-render-test"),
"log path should embed pc_id, got {}",
cfg.log.path,
);
unsafe {
std::env::remove_var("KANADE_DEV_AGENT_ID");
}
let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env unset)");
assert!(
!cfg.agent.id.is_empty(),
"pc_id should fall back to system.host"
);
assert_ne!(
cfg.agent.id, "{{ system.host }}",
"template should render, not leak"
);
}
}