use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::paths::resolve_netsky_dir;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Config {
pub schema_version: Option<u32>,
pub netsky: Option<NetskySection>,
pub owner: Option<OwnerSection>,
pub addendum: Option<AddendumSection>,
pub clones: Option<ClonesSection>,
pub channels: Option<ChannelsSection>,
pub orgs: Option<OrgsSection>,
pub tuning: Option<TuningSection>,
pub peers: Option<PeersSection>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct NetskySection {
pub dir: Option<String>,
pub machine_id: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct OwnerSection {
pub name: Option<String>,
pub imessage: Option<String>,
pub display_email: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct AddendumSection {
pub agent0: Option<String>,
pub agentinfinity: Option<String>,
pub clone_default: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ClonesSection {
pub default_count: Option<u32>,
pub default_model: Option<String>,
pub default_effort: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ChannelsSection {
pub enabled: Option<Vec<String>>,
pub imessage: Option<ImessageChannel>,
pub email: Option<EmailChannel>,
pub calendar: Option<CalendarChannel>,
pub tasks: Option<TasksChannel>,
pub drive: Option<DriveChannel>,
pub slack: Option<SlackChannel>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ImessageChannel {
pub owner_handle: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct EmailChannel {
pub allowed: Option<Vec<String>>,
pub accounts: Option<Vec<EmailAccount>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct EmailAccount {
pub primary: Option<String>,
pub send_as: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct CalendarChannel {
pub allowed: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct TasksChannel {
pub allowed: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct DriveChannel {
pub allowed: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct SlackChannel {
pub workspace_id: Option<String>,
pub bot_token_env: Option<String>,
pub allowed_channels: Option<Vec<String>>,
pub allowed_dm_users: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct OrgsSection {
pub allowed: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct TuningSection {
pub ticker_interval_s: Option<u64>,
pub agent0_hang_s: Option<u64>,
pub agent0_hang_repage_s: Option<u64>,
pub agentinit_window_s: Option<u64>,
pub agentinit_threshold: Option<u64>,
pub disk_min_mb: Option<u64>,
pub email_auto_send: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct PeersSection {
pub iroh: Option<IrohPeers>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct IrohPeers {
pub default_label: Option<String>,
#[serde(flatten)]
pub by_label: std::collections::BTreeMap<String, IrohPeer>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct IrohPeer {
pub node_id: Option<String>,
pub created: Option<String>,
pub notes: Option<String>,
}
const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
impl Config {
pub fn load() -> crate::Result<Option<Self>> {
let dir = resolve_netsky_dir();
Self::load_from(&dir.join("netsky.toml"))
}
pub fn load_from(path: &Path) -> crate::Result<Option<Self>> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(crate::anyhow!("read {}: {e}", path.display()));
}
};
let cfg: Config =
toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
if let Some(v) = cfg.schema_version
&& !SUPPORTED_SCHEMA_VERSIONS.contains(&v)
{
return Err(crate::anyhow!(
"unsupported schema_version {v} in {} (this binary supports {:?}; \
either upgrade netsky or pin schema_version to a supported value)",
path.display(),
SUPPORTED_SCHEMA_VERSIONS
));
}
Ok(Some(cfg))
}
}
pub fn netsky_toml_path() -> PathBuf {
resolve_netsky_dir().join("netsky.toml")
}
pub fn resolve<F>(env_var: &str, extract: F, default: &str) -> String
where
F: FnOnce(&Config) -> Option<String>,
{
if let Ok(v) = std::env::var(env_var)
&& !v.is_empty()
{
return v;
}
if let Some(cfg) = Config::load().ok().flatten()
&& let Some(v) = extract(&cfg)
&& !v.is_empty()
{
return v;
}
default.to_string()
}
pub fn email_allowed() -> Vec<String> {
Config::load()
.ok()
.flatten()
.and_then(|c| c.channels)
.and_then(|ch| ch.email)
.and_then(|e| e.allowed)
.unwrap_or_default()
}
pub fn email_accounts() -> Vec<EmailAccount> {
Config::load()
.ok()
.flatten()
.and_then(|c| c.channels)
.and_then(|ch| ch.email)
.and_then(|e| e.accounts)
.unwrap_or_default()
}
pub fn owner_name() -> String {
resolve(
crate::consts::ENV_OWNER_NAME,
|cfg| cfg.owner.as_ref().and_then(|o| o.name.clone()),
crate::consts::OWNER_NAME_DEFAULT,
)
}
pub fn owner_imessage() -> String {
resolve(
crate::consts::ENV_OWNER_IMESSAGE,
|cfg| cfg.owner.as_ref().and_then(|o| o.imessage.clone()),
crate::consts::OWNER_IMESSAGE_DEFAULT,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn missing_file_returns_ok_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("netsky.toml");
let cfg = Config::load_from(&path).unwrap();
assert!(cfg.is_none(), "missing file should return Ok(None)");
}
#[test]
fn empty_toml_returns_default_config() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("netsky.toml");
write(&path, "");
let cfg = Config::load_from(&path).unwrap().expect("Some");
assert_eq!(cfg, Config::default());
}
#[test]
fn full_schema_round_trips_via_example() {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest
.ancestors()
.nth(3)
.expect("repo root sits 3 levels above netsky-core's manifest");
let example = repo_root.join("netsky.toml.example");
let cfg = Config::load_from(&example)
.unwrap()
.expect("netsky.toml.example must exist + parse");
assert_eq!(cfg.schema_version, Some(1), "example: schema_version=1");
let owner = cfg.owner.as_ref().expect("owner section present");
assert_eq!(owner.name.as_deref(), Some("Cody"));
let addendum = cfg.addendum.as_ref().expect("addendum section present");
assert_eq!(
addendum.agent0.as_deref(),
Some("addenda/0-personal.md"),
"addendum.agent0 pinned to addenda/0-personal.md"
);
let tuning = cfg.tuning.as_ref().expect("tuning section present");
assert_eq!(tuning.ticker_interval_s, Some(60));
}
#[test]
fn unsupported_schema_version_errors_loudly() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("netsky.toml");
write(&path, "schema_version = 99\n");
let err = Config::load_from(&path).expect_err("schema_version=99 should error");
let msg = err.to_string();
assert!(
msg.contains("schema_version 99"),
"error should name the bad version: {msg}"
);
assert!(
msg.contains("supports"),
"error should list supported versions: {msg}"
);
}
#[test]
fn malformed_toml_returns_err() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("netsky.toml");
write(&path, "this = is not [valid toml\n");
let err = Config::load_from(&path).expect_err("malformed should err");
assert!(
err.to_string().contains("parse"),
"error should mention parse failure: {err}"
);
}
#[test]
fn partial_toml_leaves_unset_sections_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("netsky.toml");
write(
&path,
r#"
schema_version = 1
[owner]
name = "Alice"
imessage = "+15551234567"
"#,
);
let cfg = Config::load_from(&path).unwrap().expect("Some");
assert_eq!(cfg.owner.as_ref().unwrap().name.as_deref(), Some("Alice"));
assert!(cfg.tuning.is_none(), "[tuning] absent => None");
assert!(cfg.addendum.is_none(), "[addendum] absent => None");
assert!(cfg.peers.is_none(), "[peers] absent => None");
}
#[test]
fn resolve_prefers_env_over_default() {
let prior = std::env::var("NETSKY_TEST_RESOLVE").ok();
unsafe {
std::env::set_var("NETSKY_TEST_RESOLVE", "from-env");
}
let got = resolve("NETSKY_TEST_RESOLVE", |_| None, "from-default");
assert_eq!(got, "from-env");
unsafe {
match prior {
Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE", v),
None => std::env::remove_var("NETSKY_TEST_RESOLVE"),
}
}
}
#[test]
fn resolve_falls_through_to_default_when_env_and_toml_unset() {
let prior = std::env::var("NETSKY_TEST_RESOLVE_FT").ok();
unsafe {
std::env::remove_var("NETSKY_TEST_RESOLVE_FT");
}
let got = resolve("NETSKY_TEST_RESOLVE_FT", |_| None, "from-default");
assert_eq!(got, "from-default");
unsafe {
if let Some(v) = prior {
std::env::set_var("NETSKY_TEST_RESOLVE_FT", v);
}
}
}
#[test]
fn resolve_treats_empty_env_as_unset() {
let prior = std::env::var("NETSKY_TEST_RESOLVE_EMPTY").ok();
unsafe {
std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", "");
}
let got = resolve("NETSKY_TEST_RESOLVE_EMPTY", |_| None, "from-default");
assert_eq!(
got, "from-default",
"empty env should fall through to default, not return empty"
);
unsafe {
match prior {
Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", v),
None => std::env::remove_var("NETSKY_TEST_RESOLVE_EMPTY"),
}
}
}
#[test]
fn iroh_peers_keyed_by_label_via_serde_flatten() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("netsky.toml");
write(
&path,
r#"
[peers.iroh]
default_label = "personal"
[peers.iroh.work]
node_id = "abc123"
created = "2026-04-15T04:30:00Z"
notes = "work laptop"
[peers.iroh.server]
node_id = "def456"
"#,
);
let cfg = Config::load_from(&path).unwrap().expect("Some");
let iroh = cfg.peers.as_ref().unwrap().iroh.as_ref().unwrap();
assert_eq!(iroh.default_label.as_deref(), Some("personal"));
assert_eq!(iroh.by_label.len(), 2);
assert_eq!(
iroh.by_label.get("work").unwrap().node_id.as_deref(),
Some("abc123")
);
assert_eq!(
iroh.by_label.get("server").unwrap().node_id.as_deref(),
Some("def456")
);
}
}