1use std::path::{Path, PathBuf};
21
22use serde::Deserialize;
23
24use crate::paths::resolve_netsky_dir;
25
26#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
30pub struct Config {
31 pub schema_version: Option<u32>,
32 pub netsky: Option<NetskySection>,
33 pub owner: Option<OwnerSection>,
34 pub addendum: Option<AddendumSection>,
35 pub clones: Option<ClonesSection>,
36 pub channels: Option<ChannelsSection>,
37 pub orgs: Option<OrgsSection>,
38 pub tuning: Option<TuningSection>,
39 pub peers: Option<PeersSection>,
40}
41
42#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
43pub struct NetskySection {
44 pub dir: Option<String>,
45 pub machine_id: Option<String>,
46}
47
48#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
49pub struct OwnerSection {
50 pub name: Option<String>,
51 pub imessage: Option<String>,
52 pub display_email: Option<String>,
53}
54
55#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
56pub struct AddendumSection {
57 pub agent0: Option<String>,
58 pub agentinfinity: Option<String>,
59 pub clone_default: Option<String>,
60}
61
62#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
63pub struct ClonesSection {
64 pub default_count: Option<u32>,
65 pub default_model: Option<String>,
66 pub default_effort: Option<String>,
67}
68
69#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
70pub struct ChannelsSection {
71 pub enabled: Option<Vec<String>>,
72 pub imessage: Option<ImessageChannel>,
73 pub email: Option<EmailChannel>,
74 pub calendar: Option<CalendarChannel>,
75 pub tasks: Option<TasksChannel>,
76 pub drive: Option<DriveChannel>,
77 pub slack: Option<SlackChannel>,
78}
79
80#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
81pub struct ImessageChannel {
82 pub owner_handle: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
86pub struct EmailChannel {
87 pub allowed: Option<Vec<String>>,
88 pub accounts: Option<Vec<EmailAccount>>,
89}
90
91#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
92pub struct EmailAccount {
93 pub primary: Option<String>,
94 pub send_as: Option<Vec<String>>,
95}
96
97#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
102pub struct CalendarChannel {
103 pub allowed: Option<Vec<String>>,
104}
105
106#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
109pub struct TasksChannel {
110 pub allowed: Option<Vec<String>>,
111}
112
113#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
117pub struct DriveChannel {
118 pub allowed: Option<Vec<String>>,
119}
120
121#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
122pub struct SlackChannel {
123 pub workspace_id: Option<String>,
124 pub bot_token_env: Option<String>,
125 pub allowed_channels: Option<Vec<String>>,
126 pub allowed_dm_users: Option<Vec<String>>,
127}
128
129#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
130pub struct OrgsSection {
131 pub allowed: Option<Vec<String>>,
132}
133
134#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
135pub struct TuningSection {
136 pub ticker_interval_s: Option<u64>,
137 pub agent0_hang_s: Option<u64>,
138 pub agent0_hang_repage_s: Option<u64>,
139 pub agentinit_window_s: Option<u64>,
140 pub agentinit_threshold: Option<u64>,
141 pub disk_min_mb: Option<u64>,
142 pub email_auto_send: Option<bool>,
143}
144
145#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
146pub struct PeersSection {
147 pub iroh: Option<IrohPeers>,
148}
149
150#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
151pub struct IrohPeers {
152 pub default_label: Option<String>,
153 #[serde(flatten)]
158 pub by_label: std::collections::BTreeMap<String, IrohPeer>,
159}
160
161#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
162pub struct IrohPeer {
163 pub node_id: Option<String>,
164 pub created: Option<String>,
165 pub notes: Option<String>,
166}
167
168const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
172
173impl Config {
174 pub fn load() -> crate::Result<Option<Self>> {
184 let dir = resolve_netsky_dir();
185 Self::load_from(&dir.join("netsky.toml"))
186 }
187
188 pub fn load_from(path: &Path) -> crate::Result<Option<Self>> {
191 let raw = match std::fs::read_to_string(path) {
192 Ok(s) => s,
193 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
194 Err(e) => {
195 return Err(crate::anyhow!("read {}: {e}", path.display()));
196 }
197 };
198 let cfg: Config =
199 toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
200 if let Some(v) = cfg.schema_version
201 && !SUPPORTED_SCHEMA_VERSIONS.contains(&v)
202 {
203 return Err(crate::anyhow!(
204 "unsupported schema_version {v} in {} (this binary supports {:?}; \
205 either upgrade netsky or pin schema_version to a supported value)",
206 path.display(),
207 SUPPORTED_SCHEMA_VERSIONS
208 ));
209 }
210 Ok(Some(cfg))
211 }
212}
213
214pub fn netsky_toml_path() -> PathBuf {
218 resolve_netsky_dir().join("netsky.toml")
219}
220
221pub fn resolve<F>(env_var: &str, extract: F, default: &str) -> String
237where
238 F: FnOnce(&Config) -> Option<String>,
239{
240 if let Ok(v) = std::env::var(env_var)
241 && !v.is_empty()
242 {
243 return v;
244 }
245 if let Some(cfg) = Config::load().ok().flatten()
246 && let Some(v) = extract(&cfg)
247 && !v.is_empty()
248 {
249 return v;
250 }
251 default.to_string()
252}
253
254pub fn email_allowed() -> Vec<String> {
259 Config::load()
260 .ok()
261 .flatten()
262 .and_then(|c| c.channels)
263 .and_then(|ch| ch.email)
264 .and_then(|e| e.allowed)
265 .unwrap_or_default()
266}
267
268pub fn email_accounts() -> Vec<EmailAccount> {
272 Config::load()
273 .ok()
274 .flatten()
275 .and_then(|c| c.channels)
276 .and_then(|ch| ch.email)
277 .and_then(|e| e.accounts)
278 .unwrap_or_default()
279}
280
281pub fn owner_name() -> String {
284 resolve(
285 crate::consts::ENV_OWNER_NAME,
286 |cfg| cfg.owner.as_ref().and_then(|o| o.name.clone()),
287 crate::consts::OWNER_NAME_DEFAULT,
288 )
289}
290
291pub fn owner_imessage() -> Option<String> {
298 if let Ok(value) = std::env::var(crate::consts::ENV_OWNER_IMESSAGE)
299 && !value.is_empty()
300 {
301 return Some(value);
302 }
303 netsky_config::Config::load()
304 .ok()
305 .and_then(|cfg| cfg.owner.imessage_handle)
306 .filter(|value| !value.is_empty())
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use std::fs;
313
314 fn write(path: &Path, body: &str) {
315 if let Some(parent) = path.parent() {
316 fs::create_dir_all(parent).unwrap();
317 }
318 fs::write(path, body).unwrap();
319 }
320
321 #[test]
322 fn missing_file_returns_ok_none() {
323 let tmp = tempfile::tempdir().unwrap();
324 let path = tmp.path().join("netsky.toml");
325 let cfg = Config::load_from(&path).unwrap();
326 assert!(cfg.is_none(), "missing file should return Ok(None)");
327 }
328
329 #[test]
330 fn empty_toml_returns_default_config() {
331 let tmp = tempfile::tempdir().unwrap();
332 let path = tmp.path().join("netsky.toml");
333 write(&path, "");
334 let cfg = Config::load_from(&path).unwrap().expect("Some");
335 assert_eq!(cfg, Config::default());
336 }
337
338 #[test]
339 fn full_schema_round_trips_via_example() {
340 let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
345 let repo_root = manifest
346 .ancestors()
347 .nth(3)
348 .expect("repo root sits 3 levels above netsky-core's manifest");
349 let example = repo_root.join("netsky.toml.example");
350 let cfg = Config::load_from(&example)
351 .unwrap()
352 .expect("netsky.toml.example must exist + parse");
353 assert_eq!(cfg.schema_version, Some(1), "example: schema_version=1");
355 let owner = cfg.owner.as_ref().expect("owner section present");
356 assert_eq!(owner.name.as_deref(), Some("Cody"));
357 let addendum = cfg.addendum.as_ref().expect("addendum section present");
358 assert_eq!(
359 addendum.agent0.as_deref(),
360 Some("addenda/0-personal.md"),
361 "addendum.agent0 pinned to addenda/0-personal.md"
362 );
363 let tuning = cfg.tuning.as_ref().expect("tuning section present");
364 assert_eq!(tuning.ticker_interval_s, Some(60));
365 }
366
367 #[test]
368 fn unsupported_schema_version_errors_loudly() {
369 let tmp = tempfile::tempdir().unwrap();
370 let path = tmp.path().join("netsky.toml");
371 write(&path, "schema_version = 99\n");
372 let err = Config::load_from(&path).expect_err("schema_version=99 should error");
373 let msg = err.to_string();
374 assert!(
375 msg.contains("schema_version 99"),
376 "error should name the bad version: {msg}"
377 );
378 assert!(
379 msg.contains("supports"),
380 "error should list supported versions: {msg}"
381 );
382 }
383
384 #[test]
385 fn malformed_toml_returns_err() {
386 let tmp = tempfile::tempdir().unwrap();
387 let path = tmp.path().join("netsky.toml");
388 write(&path, "this = is not [valid toml\n");
389 let err = Config::load_from(&path).expect_err("malformed should err");
390 assert!(
391 err.to_string().contains("parse"),
392 "error should mention parse failure: {err}"
393 );
394 }
395
396 #[test]
397 fn partial_toml_leaves_unset_sections_none() {
398 let tmp = tempfile::tempdir().unwrap();
399 let path = tmp.path().join("netsky.toml");
400 write(
401 &path,
402 r#"
403schema_version = 1
404[owner]
405name = "Alice"
406imessage = "+15551234567"
407"#,
408 );
409 let cfg = Config::load_from(&path).unwrap().expect("Some");
410 assert_eq!(cfg.owner.as_ref().unwrap().name.as_deref(), Some("Alice"));
411 assert!(cfg.tuning.is_none(), "[tuning] absent => None");
412 assert!(cfg.addendum.is_none(), "[addendum] absent => None");
413 assert!(cfg.peers.is_none(), "[peers] absent => None");
414 }
415
416 #[test]
417 fn resolve_prefers_env_over_default() {
418 let prior = std::env::var("NETSKY_TEST_RESOLVE").ok();
419 unsafe {
420 std::env::set_var("NETSKY_TEST_RESOLVE", "from-env");
421 }
422 let got = resolve("NETSKY_TEST_RESOLVE", |_| None, "from-default");
423 assert_eq!(got, "from-env");
424 unsafe {
425 match prior {
426 Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE", v),
427 None => std::env::remove_var("NETSKY_TEST_RESOLVE"),
428 }
429 }
430 }
431
432 #[test]
433 fn resolve_falls_through_to_default_when_env_and_toml_unset() {
434 let prior = std::env::var("NETSKY_TEST_RESOLVE_FT").ok();
435 unsafe {
436 std::env::remove_var("NETSKY_TEST_RESOLVE_FT");
437 }
438 let got = resolve("NETSKY_TEST_RESOLVE_FT", |_| None, "from-default");
439 assert_eq!(got, "from-default");
440 unsafe {
441 if let Some(v) = prior {
442 std::env::set_var("NETSKY_TEST_RESOLVE_FT", v);
443 }
444 }
445 }
446
447 #[test]
448 fn resolve_treats_empty_env_as_unset() {
449 let prior = std::env::var("NETSKY_TEST_RESOLVE_EMPTY").ok();
450 unsafe {
451 std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", "");
452 }
453 let got = resolve("NETSKY_TEST_RESOLVE_EMPTY", |_| None, "from-default");
454 assert_eq!(
455 got, "from-default",
456 "empty env should fall through to default, not return empty"
457 );
458 unsafe {
459 match prior {
460 Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", v),
461 None => std::env::remove_var("NETSKY_TEST_RESOLVE_EMPTY"),
462 }
463 }
464 }
465
466 #[test]
467 fn iroh_peers_keyed_by_label_via_serde_flatten() {
468 let tmp = tempfile::tempdir().unwrap();
469 let path = tmp.path().join("netsky.toml");
470 write(
471 &path,
472 r#"
473[peers.iroh]
474default_label = "personal"
475
476[peers.iroh.work]
477node_id = "abc123"
478created = "2026-04-15T04:30:00Z"
479notes = "work laptop"
480
481[peers.iroh.server]
482node_id = "def456"
483"#,
484 );
485 let cfg = Config::load_from(&path).unwrap().expect("Some");
486 let iroh = cfg.peers.as_ref().unwrap().iroh.as_ref().unwrap();
487 assert_eq!(iroh.default_label.as_deref(), Some("personal"));
488 assert_eq!(iroh.by_label.len(), 2);
489 assert_eq!(
490 iroh.by_label.get("work").unwrap().node_id.as_deref(),
491 Some("abc123")
492 );
493 assert_eq!(
494 iroh.by_label.get("server").unwrap().node_id.as_deref(),
495 Some("def456")
496 );
497 }
498}