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}