Skip to main content

netsky_core/config/
runtime.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7const CONFIG_DIR_NAME: &str = "netsky";
8const OWNER_FILE_NAME: &str = "owner.toml";
9const CHANNELS_FILE_NAME: &str = "channels.toml";
10const ACTIVE_HOST_FILE_NAME: &str = "active-host";
11const ADDENDUM_FILE_NAME: &str = "addendum.md";
12const HOST_FILE_PREFIX: &str = "host.";
13const HOST_FILE_SUFFIX: &str = ".toml";
14const HOST_ADDENDUM_PREFIX: &str = "addendum.";
15const HOST_ADDENDUM_SUFFIX: &str = ".md";
16const ENV_MACHINE_TYPE: &str = "MACHINE_TYPE";
17const MAX_HOST_LABEL_LEN: usize = 64;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Config {
21    pub owner: Owner,
22    pub channels: Channels,
23    pub host: Option<Host>,
24    pub addendum: Addendum,
25}
26
27impl Config {
28    pub fn load() -> Result<Self> {
29        let owner = Owner::load()?;
30        let channels = Channels::load()?;
31        let host = Host::load()?;
32        let addendum = Addendum::load(host.as_ref().map(|host| host.label.as_str()))?;
33
34        let mut merged_owner = owner.clone();
35        let mut merged_channels = channels;
36        if let Some(host) = &host {
37            merged_owner.apply(&host.owner);
38            merged_channels.apply(&host.channels);
39        }
40
41        Ok(Self {
42            owner: merged_owner,
43            channels: merged_channels,
44            host,
45            addendum,
46        })
47    }
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct Owner {
52    pub imessage_handle: Option<String>,
53    pub email_addresses: Vec<String>,
54    pub email_accounts: Vec<EmailAccount>,
55    pub github_username: Option<String>,
56    pub github_orgs: Vec<String>,
57    pub claude_owner_ref: Option<String>,
58}
59
60impl Owner {
61    pub fn load() -> Result<Self> {
62        let raw: OwnerFile = load_toml_if_present(&owner_path())?;
63        Ok(Self::from_file(raw))
64    }
65
66    fn from_file(raw: OwnerFile) -> Self {
67        Self {
68            imessage_handle: raw.imessage_handle,
69            email_addresses: raw.email_addresses.unwrap_or_default(),
70            email_accounts: raw.email_accounts.unwrap_or_default(),
71            github_username: raw.github_username,
72            github_orgs: raw.github_orgs.unwrap_or_default(),
73            claude_owner_ref: raw.claude_owner_ref,
74        }
75    }
76
77    fn apply(&mut self, override_: &OwnerOverride) {
78        if let Some(value) = &override_.imessage_handle {
79            self.imessage_handle = Some(value.clone());
80        }
81        if let Some(value) = &override_.email_addresses {
82            self.email_addresses = value.clone();
83        }
84        if let Some(value) = &override_.email_accounts {
85            self.email_accounts = value.clone();
86        }
87        if let Some(value) = &override_.github_username {
88            self.github_username = Some(value.clone());
89        }
90        if let Some(value) = &override_.github_orgs {
91            self.github_orgs = value.clone();
92        }
93        if let Some(value) = &override_.claude_owner_ref {
94            self.claude_owner_ref = Some(value.clone());
95        }
96    }
97}
98
99#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
100pub struct EmailAccount {
101    pub primary: String,
102    #[serde(default)]
103    pub send_as: Vec<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Channels {
108    pub agent: bool,
109    pub email: bool,
110    pub imessage: bool,
111    pub calendar: bool,
112    pub tasks: bool,
113    pub drive: bool,
114    pub iroh: bool,
115}
116
117impl Default for Channels {
118    fn default() -> Self {
119        Self {
120            agent: true,
121            email: false,
122            imessage: false,
123            calendar: false,
124            tasks: false,
125            drive: false,
126            iroh: false,
127        }
128    }
129}
130
131impl Channels {
132    pub fn load() -> Result<Self> {
133        let raw: ChannelsFile = load_toml_if_present(&channels_path())?;
134        Ok(Self::from_file(raw))
135    }
136
137    fn from_file(raw: ChannelsFile) -> Self {
138        let mut channels = Self::default();
139        channels.agent = raw.channels.agent.map(|v| v.enabled).unwrap_or(true);
140        channels.email = raw.channels.email.map(|v| v.enabled).unwrap_or(false);
141        channels.imessage = raw.channels.imessage.map(|v| v.enabled).unwrap_or(false);
142        channels.calendar = raw.channels.calendar.map(|v| v.enabled).unwrap_or(false);
143        channels.tasks = raw.channels.tasks.map(|v| v.enabled).unwrap_or(false);
144        channels.drive = raw.channels.drive.map(|v| v.enabled).unwrap_or(false);
145        channels.iroh = raw.channels.iroh.map(|v| v.enabled).unwrap_or(false);
146        channels
147    }
148
149    fn apply(&mut self, override_: &ChannelsOverride) {
150        if let Some(value) = override_.agent {
151            self.agent = value;
152        }
153        if let Some(value) = override_.email {
154            self.email = value;
155        }
156        if let Some(value) = override_.imessage {
157            self.imessage = value;
158        }
159        if let Some(value) = override_.calendar {
160            self.calendar = value;
161        }
162        if let Some(value) = override_.tasks {
163            self.tasks = value;
164        }
165        if let Some(value) = override_.drive {
166            self.drive = value;
167        }
168        if let Some(value) = override_.iroh {
169            self.iroh = value;
170        }
171    }
172
173    pub fn is_enabled_source(&self, source: &str) -> bool {
174        match source {
175            "agent" | "demo" => true,
176            "email" => self.email,
177            "imessage" => self.imessage,
178            "calendar" => self.calendar,
179            "tasks" => self.tasks,
180            "drive" => self.drive,
181            "iroh" => self.iroh,
182            _ => false,
183        }
184    }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Host {
189    pub label: String,
190    pub owner: OwnerOverride,
191    pub channels: ChannelsOverride,
192    pub is_root: Option<bool>,
193}
194
195impl Host {
196    pub fn load() -> Result<Option<Self>> {
197        let Some(label) = active_host_label()? else {
198            return Ok(None);
199        };
200        let path = host_path(&label);
201        let raw: HostFile = load_toml_if_present(&path)?;
202        Ok(Some(Self {
203            label,
204            owner: raw.owner.unwrap_or_default(),
205            channels: raw.channels.unwrap_or_default(),
206            is_root: raw.host.and_then(|host| host.is_root),
207        }))
208    }
209}
210
211#[derive(Debug, Clone, Default, PartialEq, Eq)]
212pub struct Addendum {
213    pub base_path: PathBuf,
214    pub base: Option<String>,
215    pub host_label: Option<String>,
216    pub host_path: Option<PathBuf>,
217    pub host: Option<String>,
218}
219
220impl Addendum {
221    pub fn load(host_label: Option<&str>) -> Result<Self> {
222        let base_path = addendum_path();
223        let host_path = host_label.map(host_addendum_path);
224        let base = read_optional_string(&base_path)?;
225        let host = match host_path.as_ref() {
226            Some(path) => read_optional_string(path)?,
227            None => None,
228        };
229        Ok(Self {
230            base_path,
231            base,
232            host_label: host_label.map(ToOwned::to_owned),
233            host_path,
234            host,
235        })
236    }
237}
238
239#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
240struct OwnerFile {
241    imessage_handle: Option<String>,
242    email_addresses: Option<Vec<String>>,
243    email_accounts: Option<Vec<EmailAccount>>,
244    github_username: Option<String>,
245    github_orgs: Option<Vec<String>>,
246    claude_owner_ref: Option<String>,
247}
248
249#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
250pub struct OwnerOverride {
251    pub imessage_handle: Option<String>,
252    pub email_addresses: Option<Vec<String>>,
253    pub email_accounts: Option<Vec<EmailAccount>>,
254    pub github_username: Option<String>,
255    pub github_orgs: Option<Vec<String>>,
256    pub claude_owner_ref: Option<String>,
257}
258
259#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
260struct ChannelsFile {
261    #[serde(default)]
262    channels: ChannelsSection,
263}
264
265#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
266struct ChannelsSection {
267    agent: Option<EnabledFlag>,
268    email: Option<EnabledFlag>,
269    imessage: Option<EnabledFlag>,
270    calendar: Option<EnabledFlag>,
271    tasks: Option<EnabledFlag>,
272    drive: Option<EnabledFlag>,
273    iroh: Option<EnabledFlag>,
274}
275
276#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
277struct EnabledFlag {
278    enabled: bool,
279}
280
281#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
282pub struct ChannelsOverride {
283    pub agent: Option<bool>,
284    pub email: Option<bool>,
285    pub imessage: Option<bool>,
286    pub calendar: Option<bool>,
287    pub tasks: Option<bool>,
288    pub drive: Option<bool>,
289    pub iroh: Option<bool>,
290}
291
292#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
293struct HostFile {
294    owner: Option<OwnerOverride>,
295    channels: Option<ChannelsOverride>,
296    host: Option<HostSection>,
297}
298
299#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
300struct HostSection {
301    is_root: Option<bool>,
302}
303
304pub fn config_dir() -> PathBuf {
305    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
306        && !xdg.trim().is_empty()
307    {
308        return PathBuf::from(xdg).join(CONFIG_DIR_NAME);
309    }
310    dirs::home_dir()
311        .unwrap_or_else(|| PathBuf::from("~"))
312        .join(".config")
313        .join(CONFIG_DIR_NAME)
314}
315
316pub fn owner_path() -> PathBuf {
317    config_dir().join(OWNER_FILE_NAME)
318}
319
320pub fn channels_path() -> PathBuf {
321    config_dir().join(CHANNELS_FILE_NAME)
322}
323
324pub fn active_host_path() -> PathBuf {
325    config_dir().join(ACTIVE_HOST_FILE_NAME)
326}
327
328pub fn validate_host_label(label: &str) -> Result<&str> {
329    let trimmed = label.trim();
330    if trimmed.is_empty() {
331        anyhow::bail!("machine label cannot be empty");
332    }
333    if trimmed.len() > MAX_HOST_LABEL_LEN {
334        anyhow::bail!("machine label must be 64 characters or fewer");
335    }
336    if trimmed.starts_with('.') {
337        anyhow::bail!("machine label cannot start with '.'");
338    }
339    if trimmed.contains("..") {
340        anyhow::bail!("machine label cannot contain '..'");
341    }
342    if trimmed
343        .chars()
344        .any(|ch| std::path::is_separator(ch) || ch == '\\')
345    {
346        anyhow::bail!("machine label cannot contain path separators");
347    }
348    Ok(trimmed)
349}
350
351pub fn host_path(label: &str) -> PathBuf {
352    config_dir().join(format!("{HOST_FILE_PREFIX}{label}{HOST_FILE_SUFFIX}"))
353}
354
355pub fn addendum_path() -> PathBuf {
356    config_dir().join(ADDENDUM_FILE_NAME)
357}
358
359pub fn host_addendum_path(label: &str) -> PathBuf {
360    config_dir().join(format!(
361        "{HOST_ADDENDUM_PREFIX}{label}{HOST_ADDENDUM_SUFFIX}"
362    ))
363}
364
365pub fn active_host_label() -> Result<Option<String>> {
366    if let Ok(label) = std::env::var(ENV_MACHINE_TYPE)
367        && !label.trim().is_empty()
368    {
369        return Ok(Some(validate_host_label(&label)?.to_string()));
370    }
371    let Some(label) = read_optional_string(&active_host_path())? else {
372        return Ok(None);
373    };
374    if label.trim().is_empty() {
375        return Ok(None);
376    }
377    Ok(Some(validate_host_label(&label)?.to_string()))
378}
379
380fn read_optional_string(path: &Path) -> Result<Option<String>> {
381    match fs::read_to_string(path) {
382        Ok(value) => Ok(Some(value)),
383        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
384        Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
385    }
386}
387
388fn load_toml_if_present<T>(path: &Path) -> Result<T>
389where
390    T: Default + for<'de> Deserialize<'de>,
391{
392    let Some(raw) = read_optional_string(path)? else {
393        return Ok(T::default());
394    };
395    toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::sync::{Mutex, MutexGuard, OnceLock};
402    use tempfile::TempDir;
403
404    struct TestEnv {
405        _tmp: TempDir,
406        _guard: MutexGuard<'static, ()>,
407        prior_xdg: Option<String>,
408        prior_machine_type: Option<String>,
409    }
410
411    impl TestEnv {
412        fn new() -> Self {
413            let guard = test_lock().lock().unwrap_or_else(|err| err.into_inner());
414            let tmp = TempDir::new().unwrap();
415            let prior_xdg = std::env::var("XDG_CONFIG_HOME").ok();
416            let prior_machine_type = std::env::var(ENV_MACHINE_TYPE).ok();
417            unsafe {
418                std::env::set_var("XDG_CONFIG_HOME", tmp.path());
419                std::env::remove_var(ENV_MACHINE_TYPE);
420            }
421            Self {
422                _tmp: tmp,
423                _guard: guard,
424                prior_xdg,
425                prior_machine_type,
426            }
427        }
428    }
429
430    fn test_lock() -> &'static Mutex<()> {
431        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
432        LOCK.get_or_init(|| Mutex::new(()))
433    }
434
435    impl Drop for TestEnv {
436        fn drop(&mut self) {
437            unsafe {
438                match &self.prior_xdg {
439                    Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
440                    None => std::env::remove_var("XDG_CONFIG_HOME"),
441                }
442                match &self.prior_machine_type {
443                    Some(value) => std::env::set_var(ENV_MACHINE_TYPE, value),
444                    None => std::env::remove_var(ENV_MACHINE_TYPE),
445                }
446            }
447        }
448    }
449
450    fn write(path: &Path, body: &str) {
451        fs::create_dir_all(path.parent().unwrap()).unwrap();
452        fs::write(path, body).unwrap();
453    }
454
455    #[test]
456    fn defaults_are_safe_for_fresh_clone() {
457        let _env = TestEnv::new();
458        let cfg = Config::load().unwrap();
459        assert_eq!(cfg.owner, Owner::default());
460        assert_eq!(cfg.channels, Channels::default());
461        assert!(cfg.host.is_none());
462        assert!(cfg.addendum.base.is_none());
463        assert!(cfg.addendum.host.is_none());
464    }
465
466    #[test]
467    fn loads_owner_and_channels_files() {
468        let _env = TestEnv::new();
469        write(
470            &owner_path(),
471            r#"
472imessage_handle = "+15551234567"
473email_addresses = ["cody@example.com"]
474github_username = "lostmygithubaccount"
475github_orgs = ["dkdc-io"]
476claude_owner_ref = "cody"
477[[email_accounts]]
478primary = "cody@example.com"
479send_as = ["cody@dkdc.dev"]
480"#,
481        );
482        write(
483            &channels_path(),
484            r#"
485[channels.email]
486enabled = true
487
488[channels.imessage]
489enabled = true
490"#,
491        );
492
493        let cfg = Config::load().unwrap();
494        assert_eq!(cfg.owner.imessage_handle.as_deref(), Some("+15551234567"));
495        assert_eq!(cfg.owner.email_addresses, vec!["cody@example.com"]);
496        assert_eq!(cfg.owner.email_accounts.len(), 1);
497        assert_eq!(cfg.owner.email_accounts[0].primary, "cody@example.com");
498        assert_eq!(cfg.owner.email_accounts[0].send_as, vec!["cody@dkdc.dev"]);
499        assert_eq!(
500            cfg.owner.github_username.as_deref(),
501            Some("lostmygithubaccount")
502        );
503        assert!(cfg.channels.agent);
504        assert!(cfg.channels.email);
505        assert!(cfg.channels.imessage);
506        assert!(!cfg.channels.iroh);
507    }
508
509    #[test]
510    fn machine_type_env_beats_active_host_file() {
511        let _env = TestEnv::new();
512        write(&active_host_path(), "personal\n");
513        unsafe {
514            std::env::set_var(ENV_MACHINE_TYPE, "work");
515        }
516
517        assert_eq!(active_host_label().unwrap().as_deref(), Some("work"));
518    }
519
520    #[test]
521    fn validate_host_label_rejects_invalid_values() {
522        let cases = [
523            ("", "empty"),
524            (".work", "start with '.'"),
525            ("work/mbp", "path separators"),
526            ("work\\mbp", "path separators"),
527            ("work..mbp", "'..'"),
528            (&"w".repeat(65), "64 characters or fewer"),
529        ];
530        for (label, want) in cases {
531            let err = validate_host_label(label).unwrap_err().to_string();
532            assert!(
533                err.contains(want),
534                "label {label:?} produced {err:?}, expected {want:?}"
535            );
536        }
537    }
538
539    #[test]
540    fn active_host_label_rejects_invalid_env_label() {
541        let _env = TestEnv::new();
542        unsafe {
543            std::env::set_var(ENV_MACHINE_TYPE, "../../../tmp/pwn");
544        }
545
546        let err = active_host_label().unwrap_err().to_string();
547        assert!(err.contains("machine label cannot"), "err: {err}");
548    }
549
550    #[test]
551    fn active_host_label_rejects_invalid_file_label() {
552        let _env = TestEnv::new();
553        write(&active_host_path(), "../../../tmp/pwn\n");
554
555        let err = active_host_label().unwrap_err().to_string();
556        assert!(err.contains("machine label cannot"), "err: {err}");
557    }
558
559    #[test]
560    fn host_file_overrides_base_config() {
561        let _env = TestEnv::new();
562        write(
563            &owner_path(),
564            r#"
565imessage_handle = "+15550000000"
566email_addresses = ["personal@example.com"]
567[[email_accounts]]
568primary = "personal@example.com"
569"#,
570        );
571        write(
572            &channels_path(),
573            r#"
574[channels.imessage]
575enabled = false
576
577[channels.email]
578enabled = false
579"#,
580        );
581        write(&active_host_path(), "work\n");
582        write(
583            &host_path("work"),
584            r#"
585[owner]
586imessage_handle = "+15551111111"
587email_addresses = ["work@example.com"]
588[[owner.email_accounts]]
589primary = "work@example.com"
590send_as = ["alias@example.com"]
591
592[channels]
593imessage = true
594
595[host]
596is_root = true
597"#,
598        );
599
600        let cfg = Config::load().unwrap();
601        assert_eq!(cfg.owner.imessage_handle.as_deref(), Some("+15551111111"));
602        assert_eq!(cfg.owner.email_addresses, vec!["work@example.com"]);
603        assert_eq!(cfg.owner.email_accounts.len(), 1);
604        assert_eq!(cfg.owner.email_accounts[0].primary, "work@example.com");
605        assert_eq!(
606            cfg.owner.email_accounts[0].send_as,
607            vec!["alias@example.com"]
608        );
609        assert!(cfg.channels.imessage);
610        assert!(!cfg.channels.email);
611        assert_eq!(cfg.host.as_ref().and_then(|host| host.is_root), Some(true));
612    }
613
614    #[test]
615    fn addendum_loads_base_and_host_layers() {
616        let _env = TestEnv::new();
617        write(&active_host_path(), "work\n");
618        write(&addendum_path(), "base addendum\n");
619        write(&host_addendum_path("work"), "work addendum\n");
620
621        let cfg = Config::load().unwrap();
622        assert_eq!(cfg.addendum.base.as_deref(), Some("base addendum\n"));
623        assert_eq!(cfg.addendum.host.as_deref(), Some("work addendum\n"));
624        assert_eq!(cfg.addendum.host_label.as_deref(), Some("work"));
625    }
626
627    #[test]
628    fn parse_errors_are_not_silent() {
629        let _env = TestEnv::new();
630        write(&channels_path(), "[channels.email\nenabled = true\n");
631
632        let err = Config::load().unwrap_err().to_string();
633        assert!(err.contains("parse"));
634        assert!(err.contains("channels.toml"));
635    }
636}