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