use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
const CONFIG_DIR_NAME: &str = "netsky";
const OWNER_FILE_NAME: &str = "owner.toml";
const CHANNELS_FILE_NAME: &str = "channels.toml";
const ACTIVE_HOST_FILE_NAME: &str = "active-host";
const ADDENDUM_FILE_NAME: &str = "addendum.md";
const HOST_FILE_PREFIX: &str = "host.";
const HOST_FILE_SUFFIX: &str = ".toml";
const HOST_ADDENDUM_PREFIX: &str = "addendum.";
const HOST_ADDENDUM_SUFFIX: &str = ".md";
const ENV_MACHINE_TYPE: &str = "MACHINE_TYPE";
const MAX_HOST_LABEL_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub owner: Owner,
pub channels: Channels,
pub host: Option<Host>,
pub addendum: Addendum,
}
impl Config {
pub fn load() -> Result<Self> {
let owner = Owner::load()?;
let channels = Channels::load()?;
let host = Host::load()?;
let addendum = Addendum::load(host.as_ref().map(|host| host.label.as_str()))?;
let mut merged_owner = owner.clone();
let mut merged_channels = channels;
if let Some(host) = &host {
merged_owner.apply(&host.owner);
merged_channels.apply(&host.channels);
}
Ok(Self {
owner: merged_owner,
channels: merged_channels,
host,
addendum,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Owner {
pub imessage_handle: Option<String>,
pub email_addresses: Vec<String>,
pub email_accounts: Vec<EmailAccount>,
pub github_username: Option<String>,
pub github_orgs: Vec<String>,
pub claude_owner_ref: Option<String>,
}
impl Owner {
pub fn load() -> Result<Self> {
let raw: OwnerFile = load_toml_if_present(&owner_path())?;
Ok(Self::from_file(raw))
}
fn from_file(raw: OwnerFile) -> Self {
Self {
imessage_handle: raw.imessage_handle,
email_addresses: raw.email_addresses.unwrap_or_default(),
email_accounts: raw.email_accounts.unwrap_or_default(),
github_username: raw.github_username,
github_orgs: raw.github_orgs.unwrap_or_default(),
claude_owner_ref: raw.claude_owner_ref,
}
}
fn apply(&mut self, override_: &OwnerOverride) {
if let Some(value) = &override_.imessage_handle {
self.imessage_handle = Some(value.clone());
}
if let Some(value) = &override_.email_addresses {
self.email_addresses = value.clone();
}
if let Some(value) = &override_.email_accounts {
self.email_accounts = value.clone();
}
if let Some(value) = &override_.github_username {
self.github_username = Some(value.clone());
}
if let Some(value) = &override_.github_orgs {
self.github_orgs = value.clone();
}
if let Some(value) = &override_.claude_owner_ref {
self.claude_owner_ref = Some(value.clone());
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct EmailAccount {
pub primary: String,
#[serde(default)]
pub send_as: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Channels {
pub agent: bool,
pub email: bool,
pub imessage: bool,
pub calendar: bool,
pub tasks: bool,
pub drive: bool,
pub iroh: bool,
}
impl Default for Channels {
fn default() -> Self {
Self {
agent: true,
email: false,
imessage: false,
calendar: false,
tasks: false,
drive: false,
iroh: false,
}
}
}
impl Channels {
pub fn load() -> Result<Self> {
let raw: ChannelsFile = load_toml_if_present(&channels_path())?;
Ok(Self::from_file(raw))
}
fn from_file(raw: ChannelsFile) -> Self {
let mut channels = Self::default();
channels.agent = raw.channels.agent.map(|v| v.enabled).unwrap_or(true);
channels.email = raw.channels.email.map(|v| v.enabled).unwrap_or(false);
channels.imessage = raw.channels.imessage.map(|v| v.enabled).unwrap_or(false);
channels.calendar = raw.channels.calendar.map(|v| v.enabled).unwrap_or(false);
channels.tasks = raw.channels.tasks.map(|v| v.enabled).unwrap_or(false);
channels.drive = raw.channels.drive.map(|v| v.enabled).unwrap_or(false);
channels.iroh = raw.channels.iroh.map(|v| v.enabled).unwrap_or(false);
channels
}
fn apply(&mut self, override_: &ChannelsOverride) {
if let Some(value) = override_.agent {
self.agent = value;
}
if let Some(value) = override_.email {
self.email = value;
}
if let Some(value) = override_.imessage {
self.imessage = value;
}
if let Some(value) = override_.calendar {
self.calendar = value;
}
if let Some(value) = override_.tasks {
self.tasks = value;
}
if let Some(value) = override_.drive {
self.drive = value;
}
if let Some(value) = override_.iroh {
self.iroh = value;
}
}
pub fn is_enabled_source(&self, source: &str) -> bool {
match source {
"agent" | "demo" => true,
"email" => self.email,
"imessage" => self.imessage,
"calendar" => self.calendar,
"tasks" => self.tasks,
"drive" => self.drive,
"iroh" => self.iroh,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
pub label: String,
pub owner: OwnerOverride,
pub channels: ChannelsOverride,
pub is_root: Option<bool>,
}
impl Host {
pub fn load() -> Result<Option<Self>> {
let Some(label) = active_host_label()? else {
return Ok(None);
};
let path = host_path(&label);
let raw: HostFile = load_toml_if_present(&path)?;
Ok(Some(Self {
label,
owner: raw.owner.unwrap_or_default(),
channels: raw.channels.unwrap_or_default(),
is_root: raw.host.and_then(|host| host.is_root),
}))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Addendum {
pub base_path: PathBuf,
pub base: Option<String>,
pub host_label: Option<String>,
pub host_path: Option<PathBuf>,
pub host: Option<String>,
}
impl Addendum {
pub fn load(host_label: Option<&str>) -> Result<Self> {
let base_path = addendum_path();
let host_path = host_label.map(host_addendum_path);
let base = read_optional_string(&base_path)?;
let host = match host_path.as_ref() {
Some(path) => read_optional_string(path)?,
None => None,
};
Ok(Self {
base_path,
base,
host_label: host_label.map(ToOwned::to_owned),
host_path,
host,
})
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct OwnerFile {
imessage_handle: Option<String>,
email_addresses: Option<Vec<String>>,
email_accounts: Option<Vec<EmailAccount>>,
github_username: Option<String>,
github_orgs: Option<Vec<String>>,
claude_owner_ref: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct OwnerOverride {
pub imessage_handle: Option<String>,
pub email_addresses: Option<Vec<String>>,
pub email_accounts: Option<Vec<EmailAccount>>,
pub github_username: Option<String>,
pub github_orgs: Option<Vec<String>>,
pub claude_owner_ref: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct ChannelsFile {
#[serde(default)]
channels: ChannelsSection,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct ChannelsSection {
agent: Option<EnabledFlag>,
email: Option<EnabledFlag>,
imessage: Option<EnabledFlag>,
calendar: Option<EnabledFlag>,
tasks: Option<EnabledFlag>,
drive: Option<EnabledFlag>,
iroh: Option<EnabledFlag>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
struct EnabledFlag {
enabled: bool,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ChannelsOverride {
pub agent: Option<bool>,
pub email: Option<bool>,
pub imessage: Option<bool>,
pub calendar: Option<bool>,
pub tasks: Option<bool>,
pub drive: Option<bool>,
pub iroh: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct HostFile {
owner: Option<OwnerOverride>,
channels: Option<ChannelsOverride>,
host: Option<HostSection>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct HostSection {
is_root: Option<bool>,
}
pub fn config_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.trim().is_empty()
{
return PathBuf::from(xdg).join(CONFIG_DIR_NAME);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".config")
.join(CONFIG_DIR_NAME)
}
pub fn owner_path() -> PathBuf {
config_dir().join(OWNER_FILE_NAME)
}
pub fn channels_path() -> PathBuf {
config_dir().join(CHANNELS_FILE_NAME)
}
pub fn active_host_path() -> PathBuf {
config_dir().join(ACTIVE_HOST_FILE_NAME)
}
pub fn validate_host_label(label: &str) -> Result<&str> {
let trimmed = label.trim();
if trimmed.is_empty() {
anyhow::bail!("machine label cannot be empty");
}
if trimmed.len() > MAX_HOST_LABEL_LEN {
anyhow::bail!("machine label must be 64 characters or fewer");
}
if trimmed.starts_with('.') {
anyhow::bail!("machine label cannot start with '.'");
}
if trimmed.contains("..") {
anyhow::bail!("machine label cannot contain '..'");
}
if trimmed
.chars()
.any(|ch| std::path::is_separator(ch) || ch == '\\')
{
anyhow::bail!("machine label cannot contain path separators");
}
Ok(trimmed)
}
pub fn host_path(label: &str) -> PathBuf {
config_dir().join(format!("{HOST_FILE_PREFIX}{label}{HOST_FILE_SUFFIX}"))
}
pub fn addendum_path() -> PathBuf {
config_dir().join(ADDENDUM_FILE_NAME)
}
pub fn host_addendum_path(label: &str) -> PathBuf {
config_dir().join(format!(
"{HOST_ADDENDUM_PREFIX}{label}{HOST_ADDENDUM_SUFFIX}"
))
}
pub fn active_host_label() -> Result<Option<String>> {
if let Ok(label) = std::env::var(ENV_MACHINE_TYPE)
&& !label.trim().is_empty()
{
return Ok(Some(validate_host_label(&label)?.to_string()));
}
let Some(label) = read_optional_string(&active_host_path())? else {
return Ok(None);
};
if label.trim().is_empty() {
return Ok(None);
}
Ok(Some(validate_host_label(&label)?.to_string()))
}
fn read_optional_string(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(value) => Ok(Some(value)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
}
}
fn load_toml_if_present<T>(path: &Path) -> Result<T>
where
T: Default + for<'de> Deserialize<'de>,
{
let Some(raw) = read_optional_string(path)? else {
return Ok(T::default());
};
toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
use tempfile::TempDir;
struct TestEnv {
_tmp: TempDir,
_guard: MutexGuard<'static, ()>,
prior_xdg: Option<String>,
prior_machine_type: Option<String>,
}
impl TestEnv {
fn new() -> Self {
let guard = test_lock().lock().unwrap_or_else(|err| err.into_inner());
let tmp = TempDir::new().unwrap();
let prior_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let prior_machine_type = std::env::var(ENV_MACHINE_TYPE).ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
std::env::remove_var(ENV_MACHINE_TYPE);
}
Self {
_tmp: tmp,
_guard: guard,
prior_xdg,
prior_machine_type,
}
}
}
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
impl Drop for TestEnv {
fn drop(&mut self) {
unsafe {
match &self.prior_xdg {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match &self.prior_machine_type {
Some(value) => std::env::set_var(ENV_MACHINE_TYPE, value),
None => std::env::remove_var(ENV_MACHINE_TYPE),
}
}
}
}
fn write(path: &Path, body: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
#[test]
fn defaults_are_safe_for_fresh_clone() {
let _env = TestEnv::new();
let cfg = Config::load().unwrap();
assert_eq!(cfg.owner, Owner::default());
assert_eq!(cfg.channels, Channels::default());
assert!(cfg.host.is_none());
assert!(cfg.addendum.base.is_none());
assert!(cfg.addendum.host.is_none());
}
#[test]
fn loads_owner_and_channels_files() {
let _env = TestEnv::new();
write(
&owner_path(),
r#"
imessage_handle = "+15551234567"
email_addresses = ["cody@example.com"]
github_username = "lostmygithubaccount"
github_orgs = ["dkdc-io"]
claude_owner_ref = "cody"
[[email_accounts]]
primary = "cody@example.com"
send_as = ["cody@dkdc.dev"]
"#,
);
write(
&channels_path(),
r#"
[channels.email]
enabled = true
[channels.imessage]
enabled = true
"#,
);
let cfg = Config::load().unwrap();
assert_eq!(cfg.owner.imessage_handle.as_deref(), Some("+15551234567"));
assert_eq!(cfg.owner.email_addresses, vec!["cody@example.com"]);
assert_eq!(cfg.owner.email_accounts.len(), 1);
assert_eq!(cfg.owner.email_accounts[0].primary, "cody@example.com");
assert_eq!(cfg.owner.email_accounts[0].send_as, vec!["cody@dkdc.dev"]);
assert_eq!(
cfg.owner.github_username.as_deref(),
Some("lostmygithubaccount")
);
assert!(cfg.channels.agent);
assert!(cfg.channels.email);
assert!(cfg.channels.imessage);
assert!(!cfg.channels.iroh);
}
#[test]
fn machine_type_env_beats_active_host_file() {
let _env = TestEnv::new();
write(&active_host_path(), "personal\n");
unsafe {
std::env::set_var(ENV_MACHINE_TYPE, "work");
}
assert_eq!(active_host_label().unwrap().as_deref(), Some("work"));
}
#[test]
fn validate_host_label_rejects_invalid_values() {
let cases = [
("", "empty"),
(".work", "start with '.'"),
("work/mbp", "path separators"),
("work\\mbp", "path separators"),
("work..mbp", "'..'"),
(&"w".repeat(65), "64 characters or fewer"),
];
for (label, want) in cases {
let err = validate_host_label(label).unwrap_err().to_string();
assert!(
err.contains(want),
"label {label:?} produced {err:?}, expected {want:?}"
);
}
}
#[test]
fn active_host_label_rejects_invalid_env_label() {
let _env = TestEnv::new();
unsafe {
std::env::set_var(ENV_MACHINE_TYPE, "../../../tmp/pwn");
}
let err = active_host_label().unwrap_err().to_string();
assert!(err.contains("machine label cannot"), "err: {err}");
}
#[test]
fn active_host_label_rejects_invalid_file_label() {
let _env = TestEnv::new();
write(&active_host_path(), "../../../tmp/pwn\n");
let err = active_host_label().unwrap_err().to_string();
assert!(err.contains("machine label cannot"), "err: {err}");
}
#[test]
fn host_file_overrides_base_config() {
let _env = TestEnv::new();
write(
&owner_path(),
r#"
imessage_handle = "+15550000000"
email_addresses = ["personal@example.com"]
[[email_accounts]]
primary = "personal@example.com"
"#,
);
write(
&channels_path(),
r#"
[channels.imessage]
enabled = false
[channels.email]
enabled = false
"#,
);
write(&active_host_path(), "work\n");
write(
&host_path("work"),
r#"
[owner]
imessage_handle = "+15551111111"
email_addresses = ["work@example.com"]
[[owner.email_accounts]]
primary = "work@example.com"
send_as = ["alias@example.com"]
[channels]
imessage = true
[host]
is_root = true
"#,
);
let cfg = Config::load().unwrap();
assert_eq!(cfg.owner.imessage_handle.as_deref(), Some("+15551111111"));
assert_eq!(cfg.owner.email_addresses, vec!["work@example.com"]);
assert_eq!(cfg.owner.email_accounts.len(), 1);
assert_eq!(cfg.owner.email_accounts[0].primary, "work@example.com");
assert_eq!(
cfg.owner.email_accounts[0].send_as,
vec!["alias@example.com"]
);
assert!(cfg.channels.imessage);
assert!(!cfg.channels.email);
assert_eq!(cfg.host.as_ref().and_then(|host| host.is_root), Some(true));
}
#[test]
fn addendum_loads_base_and_host_layers() {
let _env = TestEnv::new();
write(&active_host_path(), "work\n");
write(&addendum_path(), "base addendum\n");
write(&host_addendum_path("work"), "work addendum\n");
let cfg = Config::load().unwrap();
assert_eq!(cfg.addendum.base.as_deref(), Some("base addendum\n"));
assert_eq!(cfg.addendum.host.as_deref(), Some("work addendum\n"));
assert_eq!(cfg.addendum.host_label.as_deref(), Some("work"));
}
#[test]
fn parse_errors_are_not_silent() {
let _env = TestEnv::new();
write(&channels_path(), "[channels.email\nenabled = true\n");
let err = Config::load().unwrap_err().to_string();
assert!(err.contains("parse"));
assert!(err.contains("channels.toml"));
}
}