Skip to main content

netsky_core/
config.rs

1//! Per-machine `netsky.toml` loader.
2//!
3//! Reads `$NETSKY_DIR/netsky.toml` if present; absent file = `None`,
4//! every caller falls back to env vars + `consts.rs` defaults (today's
5//! behavior on every machine). Schema lives in `netsky.toml.example`
6//! at the repo root and `briefs/netsky-config-design.md` (agent6).
7//!
8//! This is item 2 of the `netsky.toml` roadmap: parse + struct +
9//! `Config::load()`. Wiring into specific callsites (`prompt.rs`
10//! `read_cwd_addendum`, `consts.rs` owner lookups, the `[tuning]`
11//! macro) lands in follow-up commits so each callsite gets its own
12//! deliberate-break test.
13//!
14//! Precedence reminder (cited from `briefs/netsky-config-design.md`
15//! section 4): explicit CLI flag > env var > netsky.toml > consts.rs
16//! default. Env-var wins so one-off overrides do not require editing
17//! the config file. Matches docker / git / cargo conventions.
18
19use std::path::{Path, PathBuf};
20
21use serde::Deserialize;
22
23use crate::paths::resolve_netsky_dir;
24
25/// The full per-machine config. Every field is optional; a netsky.toml
26/// that omits a section means "use my prior layer's value" (env or
27/// compile-time default).
28#[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/// Calendar source allowlist. Currently the calendar source defers to the
97/// email allowlist (a primary calendar id IS the owning user's email),
98/// so this section is forward-compat scaffolding — populating it does
99/// not yet override the email-derived list.
100#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
101pub struct CalendarChannel {
102    pub allowed: Option<Vec<String>>,
103}
104
105/// Tasks source allowlist. Forward-compat scaffolding; the tasks source
106/// currently defers to the email allowlist for `account` arg validation.
107#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
108pub struct TasksChannel {
109    pub allowed: Option<Vec<String>>,
110}
111
112/// Drive source allowlist. Forward-compat scaffolding; the drive source
113/// currently defers to the email allowlist for `account` arg validation
114/// AND for `share_file` recipient gating.
115#[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    /// Per-peer entries: `[peers.iroh.<label>]` -> IrohPeer. Keys are
153    /// arbitrary labels chosen by the owner. v0 ships env-var pairing
154    /// (NETSKY_IROH_PEER_<LABEL>_NODEID); the toml-persisted form lands
155    /// when the CLI pair-show / pair-add flow ships.
156    #[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
167/// Schema versions this loader knows how to parse. Older versions get
168/// an explicit error; the alternative is silent misparse on schema
169/// drift.
170const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
171
172impl Config {
173    /// Load from `$NETSKY_DIR/netsky.toml`. Returns:
174    ///
175    /// - `Ok(None)` if no netsky checkout was resolved OR the file is
176    ///   absent. This is the steady state on every Cody-machine until
177    ///   the owner copies `netsky.toml.example` and edits.
178    /// - `Ok(Some(cfg))` on a successful parse.
179    /// - `Err(...)` on a present-but-invalid file. Callers MUST surface
180    ///   the error rather than silently fall back: a malformed config
181    ///   is a config bug, not a missing-file case.
182    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    /// Load from an explicit path. Used by tests and any caller that
190    /// needs to pin a non-default location.
191    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
215/// Resolve the path netsky.toml WOULD live at, even if the file is
216/// missing. Useful for status output and the future `netsky init`
217/// scaffolder. Returns `None` when no netsky dir resolves at all.
218pub fn netsky_toml_path() -> Option<PathBuf> {
219    resolve_netsky_dir().map(|d| d.join("netsky.toml"))
220}
221
222// ---- value-resolution helpers (env > toml > default) ----------------------
223//
224// These wrap the precedence chain agent6's brief pinned in section 4
225// (env > netsky.toml > consts.rs default). Each helper takes an env
226// var name + a config-section-extracter + a compile-time default and
227// returns the first non-empty value. Callers call the helper instead of
228// reading env directly, so adding netsky.toml support to a new tunable
229// is a one-line edit at the callsite.
230
231/// Read `env_var`; if unset or empty, consult the `extract` closure on
232/// a freshly-loaded `Config`; if still unset, return `default`.
233///
234/// Errors loading the TOML are silently swallowed (treated as "no toml
235/// value available"). Callers that need to surface a malformed config
236/// should call [`Config::load`] directly and handle the error.
237pub 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
255/// Email source allowlist (`[channels.email] allowed = [...]`). Returns
256/// an empty vec when no netsky.toml is present or the section is unset.
257/// Empty list = source is inert (every recipient rejected): the safe
258/// default for a fresh clone with no per-machine config.
259pub 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
269/// Email source primary accounts (`[[channels.email.accounts]]`). Returns
270/// an empty vec when unset. Each `EmailAccount` carries a `primary`
271/// address and an optional list of `send_as` aliases.
272pub 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
282/// Resolved owner display name. Substituted into prompt templates that
283/// address the owner by name (e.g. `prompts/tick-request.md`).
284pub 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
292/// Resolved owner iMessage handle. Used by `netsky escalate` to text
293/// the owner from the watchdog floor without involving any MCP.
294pub 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        // The repo-root netsky.toml.example IS the schema documentation;
334        // make sure it parses cleanly so it stays trustworthy. This
335        // also catches future schema additions where someone forgets
336        // to add a struct field.
337        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        // Pin a few fields to catch regression if the example drifts.
347        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}