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() -> String {
294 resolve(
295 crate::consts::ENV_OWNER_IMESSAGE,
296 |cfg| cfg.owner.as_ref().and_then(|o| o.imessage.clone()),
297 crate::consts::OWNER_IMESSAGE_DEFAULT,
298 )
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::fs;
305
306 fn write(path: &Path, body: &str) {
307 if let Some(parent) = path.parent() {
308 fs::create_dir_all(parent).unwrap();
309 }
310 fs::write(path, body).unwrap();
311 }
312
313 #[test]
314 fn missing_file_returns_ok_none() {
315 let tmp = tempfile::tempdir().unwrap();
316 let path = tmp.path().join("netsky.toml");
317 let cfg = Config::load_from(&path).unwrap();
318 assert!(cfg.is_none(), "missing file should return Ok(None)");
319 }
320
321 #[test]
322 fn empty_toml_returns_default_config() {
323 let tmp = tempfile::tempdir().unwrap();
324 let path = tmp.path().join("netsky.toml");
325 write(&path, "");
326 let cfg = Config::load_from(&path).unwrap().expect("Some");
327 assert_eq!(cfg, Config::default());
328 }
329
330 #[test]
331 fn full_schema_round_trips_via_example() {
332 let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
337 let repo_root = manifest
338 .ancestors()
339 .nth(3)
340 .expect("repo root sits 3 levels above netsky-core's manifest");
341 let example = repo_root.join("netsky.toml.example");
342 let cfg = Config::load_from(&example)
343 .unwrap()
344 .expect("netsky.toml.example must exist + parse");
345 assert_eq!(cfg.schema_version, Some(1), "example: schema_version=1");
347 let owner = cfg.owner.as_ref().expect("owner section present");
348 assert_eq!(owner.name.as_deref(), Some("Cody"));
349 let addendum = cfg.addendum.as_ref().expect("addendum section present");
350 assert_eq!(
351 addendum.agent0.as_deref(),
352 Some("addenda/0-personal.md"),
353 "addendum.agent0 pinned to addenda/0-personal.md"
354 );
355 let tuning = cfg.tuning.as_ref().expect("tuning section present");
356 assert_eq!(tuning.ticker_interval_s, Some(60));
357 }
358
359 #[test]
360 fn unsupported_schema_version_errors_loudly() {
361 let tmp = tempfile::tempdir().unwrap();
362 let path = tmp.path().join("netsky.toml");
363 write(&path, "schema_version = 99\n");
364 let err = Config::load_from(&path).expect_err("schema_version=99 should error");
365 let msg = err.to_string();
366 assert!(
367 msg.contains("schema_version 99"),
368 "error should name the bad version: {msg}"
369 );
370 assert!(
371 msg.contains("supports"),
372 "error should list supported versions: {msg}"
373 );
374 }
375
376 #[test]
377 fn malformed_toml_returns_err() {
378 let tmp = tempfile::tempdir().unwrap();
379 let path = tmp.path().join("netsky.toml");
380 write(&path, "this = is not [valid toml\n");
381 let err = Config::load_from(&path).expect_err("malformed should err");
382 assert!(
383 err.to_string().contains("parse"),
384 "error should mention parse failure: {err}"
385 );
386 }
387
388 #[test]
389 fn partial_toml_leaves_unset_sections_none() {
390 let tmp = tempfile::tempdir().unwrap();
391 let path = tmp.path().join("netsky.toml");
392 write(
393 &path,
394 r#"
395schema_version = 1
396[owner]
397name = "Alice"
398imessage = "+15551234567"
399"#,
400 );
401 let cfg = Config::load_from(&path).unwrap().expect("Some");
402 assert_eq!(cfg.owner.as_ref().unwrap().name.as_deref(), Some("Alice"));
403 assert!(cfg.tuning.is_none(), "[tuning] absent => None");
404 assert!(cfg.addendum.is_none(), "[addendum] absent => None");
405 assert!(cfg.peers.is_none(), "[peers] absent => None");
406 }
407
408 #[test]
409 fn resolve_prefers_env_over_default() {
410 let prior = std::env::var("NETSKY_TEST_RESOLVE").ok();
411 unsafe {
412 std::env::set_var("NETSKY_TEST_RESOLVE", "from-env");
413 }
414 let got = resolve("NETSKY_TEST_RESOLVE", |_| None, "from-default");
415 assert_eq!(got, "from-env");
416 unsafe {
417 match prior {
418 Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE", v),
419 None => std::env::remove_var("NETSKY_TEST_RESOLVE"),
420 }
421 }
422 }
423
424 #[test]
425 fn resolve_falls_through_to_default_when_env_and_toml_unset() {
426 let prior = std::env::var("NETSKY_TEST_RESOLVE_FT").ok();
427 unsafe {
428 std::env::remove_var("NETSKY_TEST_RESOLVE_FT");
429 }
430 let got = resolve("NETSKY_TEST_RESOLVE_FT", |_| None, "from-default");
431 assert_eq!(got, "from-default");
432 unsafe {
433 if let Some(v) = prior {
434 std::env::set_var("NETSKY_TEST_RESOLVE_FT", v);
435 }
436 }
437 }
438
439 #[test]
440 fn resolve_treats_empty_env_as_unset() {
441 let prior = std::env::var("NETSKY_TEST_RESOLVE_EMPTY").ok();
442 unsafe {
443 std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", "");
444 }
445 let got = resolve("NETSKY_TEST_RESOLVE_EMPTY", |_| None, "from-default");
446 assert_eq!(
447 got, "from-default",
448 "empty env should fall through to default, not return empty"
449 );
450 unsafe {
451 match prior {
452 Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", v),
453 None => std::env::remove_var("NETSKY_TEST_RESOLVE_EMPTY"),
454 }
455 }
456 }
457
458 #[test]
459 fn iroh_peers_keyed_by_label_via_serde_flatten() {
460 let tmp = tempfile::tempdir().unwrap();
461 let path = tmp.path().join("netsky.toml");
462 write(
463 &path,
464 r#"
465[peers.iroh]
466default_label = "personal"
467
468[peers.iroh.work]
469node_id = "abc123"
470created = "2026-04-15T04:30:00Z"
471notes = "work laptop"
472
473[peers.iroh.server]
474node_id = "def456"
475"#,
476 );
477 let cfg = Config::load_from(&path).unwrap().expect("Some");
478 let iroh = cfg.peers.as_ref().unwrap().iroh.as_ref().unwrap();
479 assert_eq!(iroh.default_label.as_deref(), Some("personal"));
480 assert_eq!(iroh.by_label.len(), 2);
481 assert_eq!(
482 iroh.by_label.get("work").unwrap().node_id.as_deref(),
483 Some("abc123")
484 );
485 assert_eq!(
486 iroh.by_label.get("server").unwrap().node_id.as_deref(),
487 Some("def456")
488 );
489 }
490}