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