Skip to main content

netsky_core/
config.rs

1//! Per-machine `netsky.toml` loader.
2//!
3//! Reads `netsky.toml` under the resolved netsky root if present;
4//! absent file = `None`, every caller falls back to env vars +
5//! `consts.rs` defaults (today's behavior on every machine). Schema
6//! lives in `netsky.toml.example` at the repo root and the config
7//! design brief (agent6).
8//!
9//! This is item 2 of the `netsky.toml` roadmap: parse + struct +
10//! `NetskyToml::load()`. Wiring into specific callsites (`prompt.rs`
11//! `read_cwd_addendum`, `consts.rs` owner lookups, the `[tuning]`
12//! macro) lands in follow-up commits so each callsite gets its own
13//! deliberate-break test.
14//!
15//! Precedence reminder (from the config design brief section 4):
16//! explicit CLI flag > env var > netsky.toml > consts.rs
17//! default. Env-var wins so one-off overrides do not require editing
18//! the config file. Matches docker / git / cargo conventions.
19
20pub 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/// The full per-machine config. Every field is optional; a netsky.toml
31/// that omits a section means "use my prior layer's value" (env or
32/// compile-time default).
33#[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/// Calendar source allowlist. Currently the calendar source defers to the
108/// email allowlist (a primary calendar id IS the owning user's email),
109/// so this section is forward-compat scaffolding — populating it does
110/// not yet override the email-derived list.
111#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
112pub struct CalendarChannel {
113    pub allowed: Option<Vec<String>>,
114}
115
116/// Tasks source allowlist. Forward-compat scaffolding; the tasks source
117/// currently defers to the email allowlist for `account` arg validation.
118#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
119pub struct TasksChannel {
120    pub allowed: Option<Vec<String>>,
121}
122
123/// Drive source allowlist. Forward-compat scaffolding; the drive source
124/// currently defers to the email allowlist for `account` arg validation
125/// AND for `share_file` recipient gating.
126#[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    /// Per-peer entries: `[peers.iroh.<label>]` -> IrohPeer. Keys are
164    /// arbitrary labels chosen by the owner. v0 ships env-var pairing
165    /// (NETSKY_IROH_PEER_<LABEL>_NODEID); the toml-persisted form lands
166    /// when the CLI pair-show / pair-add flow ships.
167    #[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
178/// Schema versions this loader knows how to parse. Older versions get
179/// an explicit error; the alternative is silent misparse on schema
180/// drift.
181const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
182
183impl NetskyToml {
184    /// Load from the resolved netsky root's `netsky.toml`. Returns:
185    ///
186    /// - `Ok(None)` if the file is absent. This is the steady state on
187    ///   every Cody-machine until the owner copies `netsky.toml.example`
188    ///   and edits.
189    /// - `Ok(Some(cfg))` on a successful parse.
190    /// - `Err(...)` on a present-but-invalid file. Callers MUST surface
191    ///   the error rather than silently fall back: a malformed config
192    ///   is a config bug, not a missing-file case.
193    pub fn load() -> crate::Result<Option<Self>> {
194        let dir = resolve_netsky_dir();
195        Self::load_from(&dir.join("netsky.toml"))
196    }
197
198    /// Load from an explicit path. Used by tests and any caller that
199    /// needs to pin a non-default location.
200    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
224/// Resolve the path netsky.toml WOULD live at, even if the file is
225/// missing. Useful for status output and the future `netsky init`
226/// scaffolder.
227pub fn netsky_toml_path() -> PathBuf {
228    resolve_netsky_dir().join("netsky.toml")
229}
230
231// ---- value-resolution helpers (env > toml > default) ----------------------
232//
233// These wrap the precedence chain agent6's brief pinned in section 4
234// (env > netsky.toml > consts.rs default). Each helper takes an env
235// var name + a config-section-extracter + a compile-time default and
236// returns the first non-empty value. Callers call the helper instead of
237// reading env directly, so adding netsky.toml support to a new tunable
238// is a one-line edit at the callsite.
239
240/// Read `env_var`; if unset or empty, consult the `extract` closure on
241/// a freshly-loaded `NetskyToml`; if still unset, return `default`.
242///
243/// Errors loading the TOML are silently swallowed (treated as "no toml
244/// value available"). Callers that need to surface a malformed config
245/// should call [`NetskyToml::load`] directly and handle the error.
246pub 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
264/// Email source allowlist (`[channels.email] allowed = [...]`). Returns
265/// an empty vec when no netsky.toml is present or the section is unset.
266/// Empty list = source is inert (every recipient rejected): the safe
267/// default for a fresh clone with no per-machine config.
268pub 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
278/// Email source primary accounts (`[[channels.email.accounts]]`). Returns
279/// an empty vec when unset. Each `TomlEmailAccount` carries a `primary`
280/// address and an optional list of `send_as` aliases.
281pub 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
291/// Workspace clone mirror remotes (`[workspace] mirror_remotes = ["name=url"]`).
292/// Returns an empty vec when no netsky.toml is present or the section is unset.
293pub 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
302/// Resolved owner display name. Substituted into prompt templates that
303/// address the owner by name (e.g. `prompts/tick-request.md`).
304pub 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
312/// Resolved owner iMessage handle. Used by `netsky escalate` to text
313/// the owner from the watchdog floor without involving any MCP.
314///
315/// Precedence is env var first, then `owner.toml`. Missing config is
316/// deliberate now: the escalate caller surfaces "run bin/onboard"
317/// instead of silently texting a baked number.
318pub 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        // The repo-root netsky.toml.example IS the schema documentation;
362        // make sure it parses cleanly so it stays trustworthy. This
363        // also catches future schema additions where someone forgets
364        // to add a struct field.
365        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        // Pin a few fields to catch regression if the example drifts.
375        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}