use figment::{
Figment,
providers::{Format, Toml},
};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub identity: IdentityCfg,
pub network: NetworkCfg,
pub limits: LimitsCfg,
pub roster: RosterCfg,
pub services: std::collections::BTreeMap<String, ServiceCfg>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ServiceCfg {
pub run: Option<Vec<String>>,
pub socket: Option<String>,
pub allow: Vec<String>,
}
#[derive(Debug)]
pub enum Backend<'a> {
Run(&'a [String]),
Socket(&'a str),
}
impl ServiceCfg {
#[allow(dead_code)] pub fn backend_result(&self) -> Result<Backend<'_>, String> {
match (&self.run, &self.socket) {
(Some(cmd), None) => Ok(Backend::Run(cmd.as_slice())),
(None, Some(p)) => Ok(Backend::Socket(p.as_str())),
(Some(_), Some(_)) => Err("service has both run and socket".into()),
(None, None) => Err("service has neither run nor socket".into()),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct IdentityCfg {
pub device_key: Option<PathBuf>, pub nickname: Option<String>,
pub org_id: Option<String>,
pub org_root_pk: Option<String>,
pub user_id: Option<String>,
pub user_key: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct NetworkCfg {
pub relay_mode: String,
pub relay_urls: Vec<String>,
pub discovery_mode: String,
pub discovery_urls: Vec<String>,
}
impl Default for NetworkCfg {
fn default() -> Self {
Self {
relay_mode: "default".into(),
relay_urls: Vec::new(),
discovery_mode: "default".into(),
discovery_urls: Vec::new(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct LimitsCfg {
pub rate_limit_per_min: u32,
pub max_inflight: u32,
pub max_sessions: u32,
}
impl Default for LimitsCfg {
fn default() -> Self {
Self {
rate_limit_per_min: 120,
max_inflight: 16,
max_sessions: 4,
}
}
}
const DEFAULT_GRACE_SECS: i64 = 72 * 3600;
const DEFAULT_MAX_STALENESS_SECS: i64 = 24 * 3600;
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct RosterCfg {
pub grace_period: String,
pub url: Option<String>,
pub poll_interval: String,
pub max_staleness: String,
}
impl Default for RosterCfg {
fn default() -> Self {
Self {
grace_period: "72h".into(),
url: None,
poll_interval: "1h".into(),
max_staleness: "24h".into(),
}
}
}
impl RosterCfg {
pub fn grace_seconds(&self) -> i64 {
parse_duration(&self.grace_period).unwrap_or(DEFAULT_GRACE_SECS)
}
pub fn poll_interval_seconds(&self) -> i64 {
parse_duration(&self.poll_interval).unwrap_or(3600)
}
pub fn max_staleness_seconds(&self) -> i64 {
parse_duration(&self.max_staleness).unwrap_or(DEFAULT_MAX_STALENESS_SECS)
}
}
pub fn parse_duration(s: &str) -> Result<i64, String> {
let s = s.trim();
let (num, mult) = if let Some(n) = s.strip_suffix('d') {
(n, 24 * 3600)
} else if let Some(n) = s.strip_suffix('h') {
(n, 3600)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60)
} else if let Some(n) = s.strip_suffix('s') {
(n, 1)
} else {
(s, 1)
};
num.trim()
.parse::<u64>()
.ok()
.and_then(|v| v.checked_mul(mult))
.and_then(|v| i64::try_from(v).ok())
.ok_or_else(|| format!("unparseable duration: {s}"))
}
#[allow(clippy::result_large_err)]
impl Config {
#[allow(dead_code)] pub fn from_toml_str(s: &str) -> Result<Self, figment::Error> {
Figment::new().merge(Toml::string(s)).extract()
}
pub fn load(path: &std::path::Path) -> Result<Self, figment::Error> {
Figment::new().merge(Toml::file(path)).extract()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_file_yields_spec_defaults() {
let c = Config::from_toml_str("").unwrap();
assert_eq!(c.network.relay_mode, "default");
assert_eq!(c.network.discovery_mode, "default");
assert_eq!(c.limits.rate_limit_per_min, 120);
assert_eq!(c.limits.max_inflight, 16);
assert_eq!(c.limits.max_sessions, 4);
}
#[test]
fn values_override_defaults() {
let c = Config::from_toml_str(
"[network]\nrelay_mode = \"disabled\"\n[limits]\nrate_limit_per_min = 60\n",
)
.unwrap();
assert_eq!(c.network.relay_mode, "disabled");
assert_eq!(c.limits.rate_limit_per_min, 60);
assert_eq!(c.limits.max_inflight, 16);
}
#[test]
fn legacy_max_frame_key_is_ignored_not_an_error() {
let c =
Config::from_toml_str("[limits]\nmax_frame = \"1MiB\"\nmax_sessions = 2\n").unwrap();
assert_eq!(c.limits.max_sessions, 2);
}
#[test]
fn network_relay_and_discovery_urls_parse() {
let c = Config::from_toml_str(
"[network]\nrelay_mode = \"custom\"\nrelay_urls = [\"https://relay.acme.com\"]\n\
discovery_mode = \"custom\"\ndiscovery_urls = [\"https://dns.acme.com/pkarr\"]\n",
)
.unwrap();
assert_eq!(c.network.relay_mode, "custom");
assert_eq!(
c.network.relay_urls,
vec!["https://relay.acme.com".to_string()]
);
assert_eq!(c.network.discovery_mode, "custom");
assert_eq!(
c.network.discovery_urls,
vec!["https://dns.acme.com/pkarr".to_string()]
);
let c = Config::from_toml_str("").unwrap();
assert!(c.network.relay_urls.is_empty() && c.network.discovery_urls.is_empty());
}
#[test]
fn missing_file_loads_defaults() {
let dir = tempfile::tempdir().unwrap();
let c = Config::load(&dir.path().join("nope.toml")).unwrap();
assert_eq!(c.network.relay_mode, "default");
}
#[test]
fn roster_url_and_poll_interval_parse_with_defaults() {
let c = Config::from_toml_str("").unwrap();
assert!(c.roster.url.is_none());
assert_eq!(c.roster.poll_interval_seconds(), 3600);
let c = Config::from_toml_str(
"[roster]\nurl = \"https://intranet.acme.com/roster.json\"\npoll_interval = \"30m\"\n",
)
.unwrap();
assert_eq!(
c.roster.url.as_deref(),
Some("https://intranet.acme.com/roster.json")
);
assert_eq!(c.roster.poll_interval_seconds(), 30 * 60);
let c = Config::from_toml_str("[roster]\npoll_interval = \"never\"\n").unwrap();
assert_eq!(c.roster.poll_interval_seconds(), 3600);
let c = Config::from_toml_str("[roster]\ngrace_period = \"24h\"\n").unwrap();
assert!(c.roster.url.is_none());
assert_eq!(c.roster.poll_interval_seconds(), 3600);
}
#[test]
fn roster_max_staleness_defaults_to_24h_and_parses() {
let c = Config::from_toml_str("").unwrap();
assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
let c = Config::from_toml_str("[roster]\nmax_staleness = \"6h\"\n").unwrap();
assert_eq!(c.roster.max_staleness_seconds(), 6 * 3600);
let c = Config::from_toml_str("[roster]\nmax_staleness = \"forever\"\n").unwrap();
assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
let c = Config::from_toml_str("[roster]\ngrace_period = \"48h\"\n").unwrap();
assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
}
#[test]
fn roster_grace_defaults_to_72h_and_parses_units() {
let c = Config::from_toml_str("").unwrap();
assert_eq!(c.roster.grace_seconds(), 72 * 3600);
for (body, want) in [
("[roster]\ngrace_period = \"24h\"\n", 24 * 3600),
("[roster]\ngrace_period = \"72h\"\n", 72 * 3600),
("[roster]\ngrace_period = \"1d\"\n", 24 * 3600),
("[roster]\ngrace_period = \"30m\"\n", 30 * 60),
("[roster]\ngrace_period = \"90s\"\n", 90),
("[roster]\ngrace_period = \"3600\"\n", 3600), ] {
assert_eq!(
Config::from_toml_str(body).unwrap().roster.grace_seconds(),
want,
"{body}"
);
}
}
#[test]
fn roster_grace_unparseable_or_negative_falls_back_to_default() {
for body in [
"[roster]\ngrace_period = \"seventy-two hours\"\n",
"[roster]\ngrace_period = \"-5h\"\n",
"[roster]\ngrace_period = \"18446744073709551615d\"\n", "[roster]\ngrace_period = \"\"\n",
] {
assert_eq!(
Config::from_toml_str(body).unwrap().roster.grace_seconds(),
72 * 3600,
"{body}"
);
}
}
#[test]
fn services_parse_run_and_socket() {
let c = Config::from_toml_str(concat!(
"[services.notes]\nrun = [\"npx\", \"server\"]\nallow = [\"bob\"]\n",
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"team-eng\"]\n",
))
.unwrap();
let notes = c.services.get("notes").unwrap();
assert!(
matches!(notes.backend_result(), Ok(Backend::Run(cmd)) if cmd == &["npx".to_string(), "server".to_string()][..])
);
assert_eq!(notes.allow, vec!["bob".to_string()]);
assert!(
matches!(c.services.get("kb").unwrap().backend_result(), Ok(Backend::Socket(p)) if p == "/run/kb.sock")
);
}
#[test]
fn service_with_both_run_and_socket_is_an_error() {
let e = Config::from_toml_str("[services.x]\nrun=[\"a\"]\nsocket=\"/s\"\nallow=[]\n");
assert!(
e.unwrap()
.services
.get("x")
.unwrap()
.backend_result()
.is_err()
);
}
#[test]
fn identity_reads_user_id_and_user_key() {
let toml = "[identity]\n\
org_id = \"acme\"\n\
org_root_pk = \"b64u:AAAA\"\n\
user_id = \"alice\"\n\
user_key = \"/home/alice/.config/mcpmesh/user.key\"\n";
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.identity.user_id.as_deref(), Some("alice"));
assert_eq!(
cfg.identity.user_key.as_deref(),
Some(std::path::Path::new("/home/alice/.config/mcpmesh/user.key"))
);
let bare: Config = toml::from_str("[identity]\n").unwrap();
assert!(bare.identity.user_id.is_none() && bare.identity.user_key.is_none());
}
}