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
7//! `briefs/netsky-config-design.md` (agent6).
8//!
9//! This is item 2 of the `netsky.toml` roadmap: parse + struct +
10//! `Config::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 (cited from `briefs/netsky-config-design.md`
16//! section 4): 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
20use std::path::{Path, PathBuf};
21
22use serde::Deserialize;
23
24use crate::paths::resolve_netsky_dir;
25
26/// The full per-machine config. Every field is optional; a netsky.toml
27/// that omits a section means "use my prior layer's value" (env or
28/// compile-time default).
29#[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/// Calendar source allowlist. Currently the calendar source defers to the
98/// email allowlist (a primary calendar id IS the owning user's email),
99/// so this section is forward-compat scaffolding — populating it does
100/// not yet override the email-derived list.
101#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
102pub struct CalendarChannel {
103    pub allowed: Option<Vec<String>>,
104}
105
106/// Tasks source allowlist. Forward-compat scaffolding; the tasks source
107/// currently defers to the email allowlist for `account` arg validation.
108#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
109pub struct TasksChannel {
110    pub allowed: Option<Vec<String>>,
111}
112
113/// Drive source allowlist. Forward-compat scaffolding; the drive source
114/// currently defers to the email allowlist for `account` arg validation
115/// AND for `share_file` recipient gating.
116#[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    /// Per-peer entries: `[peers.iroh.<label>]` -> IrohPeer. Keys are
154    /// arbitrary labels chosen by the owner. v0 ships env-var pairing
155    /// (NETSKY_IROH_PEER_<LABEL>_NODEID); the toml-persisted form lands
156    /// when the CLI pair-show / pair-add flow ships.
157    #[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
168/// Schema versions this loader knows how to parse. Older versions get
169/// an explicit error; the alternative is silent misparse on schema
170/// drift.
171const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1];
172
173impl Config {
174    /// Load from the resolved netsky root's `netsky.toml`. Returns:
175    ///
176    /// - `Ok(None)` if the file is absent. This is the steady state on
177    ///   every Cody-machine until the owner copies `netsky.toml.example`
178    ///   and edits.
179    /// - `Ok(Some(cfg))` on a successful parse.
180    /// - `Err(...)` on a present-but-invalid file. Callers MUST surface
181    ///   the error rather than silently fall back: a malformed config
182    ///   is a config bug, not a missing-file case.
183    pub fn load() -> crate::Result<Option<Self>> {
184        let dir = resolve_netsky_dir();
185        Self::load_from(&dir.join("netsky.toml"))
186    }
187
188    /// Load from an explicit path. Used by tests and any caller that
189    /// needs to pin a non-default location.
190    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
214/// Resolve the path netsky.toml WOULD live at, even if the file is
215/// missing. Useful for status output and the future `netsky init`
216/// scaffolder.
217pub fn netsky_toml_path() -> PathBuf {
218    resolve_netsky_dir().join("netsky.toml")
219}
220
221// ---- value-resolution helpers (env > toml > default) ----------------------
222//
223// These wrap the precedence chain agent6's brief pinned in section 4
224// (env > netsky.toml > consts.rs default). Each helper takes an env
225// var name + a config-section-extracter + a compile-time default and
226// returns the first non-empty value. Callers call the helper instead of
227// reading env directly, so adding netsky.toml support to a new tunable
228// is a one-line edit at the callsite.
229
230/// Read `env_var`; if unset or empty, consult the `extract` closure on
231/// a freshly-loaded `Config`; if still unset, return `default`.
232///
233/// Errors loading the TOML are silently swallowed (treated as "no toml
234/// value available"). Callers that need to surface a malformed config
235/// should call [`Config::load`] directly and handle the error.
236pub 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
254/// Email source allowlist (`[channels.email] allowed = [...]`). Returns
255/// an empty vec when no netsky.toml is present or the section is unset.
256/// Empty list = source is inert (every recipient rejected): the safe
257/// default for a fresh clone with no per-machine config.
258pub 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
268/// Email source primary accounts (`[[channels.email.accounts]]`). Returns
269/// an empty vec when unset. Each `EmailAccount` carries a `primary`
270/// address and an optional list of `send_as` aliases.
271pub 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
281/// Resolved owner display name. Substituted into prompt templates that
282/// address the owner by name (e.g. `prompts/tick-request.md`).
283pub 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
291/// Resolved owner iMessage handle. Used by `netsky escalate` to text
292/// the owner from the watchdog floor without involving any MCP.
293///
294/// Precedence is env var first, then `owner.toml`. Missing config is
295/// deliberate now: the escalate caller surfaces "run bin/onboard"
296/// instead of silently texting a baked number.
297pub fn owner_imessage() -> Option<String> {
298    if let Ok(value) = std::env::var(crate::consts::ENV_OWNER_IMESSAGE)
299        && !value.is_empty()
300    {
301        return Some(value);
302    }
303    netsky_config::Config::load()
304        .ok()
305        .and_then(|cfg| cfg.owner.imessage_handle)
306        .filter(|value| !value.is_empty())
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::fs;
313
314    fn write(path: &Path, body: &str) {
315        if let Some(parent) = path.parent() {
316            fs::create_dir_all(parent).unwrap();
317        }
318        fs::write(path, body).unwrap();
319    }
320
321    #[test]
322    fn missing_file_returns_ok_none() {
323        let tmp = tempfile::tempdir().unwrap();
324        let path = tmp.path().join("netsky.toml");
325        let cfg = Config::load_from(&path).unwrap();
326        assert!(cfg.is_none(), "missing file should return Ok(None)");
327    }
328
329    #[test]
330    fn empty_toml_returns_default_config() {
331        let tmp = tempfile::tempdir().unwrap();
332        let path = tmp.path().join("netsky.toml");
333        write(&path, "");
334        let cfg = Config::load_from(&path).unwrap().expect("Some");
335        assert_eq!(cfg, Config::default());
336    }
337
338    #[test]
339    fn full_schema_round_trips_via_example() {
340        // The repo-root netsky.toml.example IS the schema documentation;
341        // make sure it parses cleanly so it stays trustworthy. This
342        // also catches future schema additions where someone forgets
343        // to add a struct field.
344        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
345        let repo_root = manifest
346            .ancestors()
347            .nth(3)
348            .expect("repo root sits 3 levels above netsky-core's manifest");
349        let example = repo_root.join("netsky.toml.example");
350        let cfg = Config::load_from(&example)
351            .unwrap()
352            .expect("netsky.toml.example must exist + parse");
353        // Pin a few fields to catch regression if the example drifts.
354        assert_eq!(cfg.schema_version, Some(1), "example: schema_version=1");
355        let owner = cfg.owner.as_ref().expect("owner section present");
356        assert_eq!(owner.name.as_deref(), Some("Cody"));
357        let addendum = cfg.addendum.as_ref().expect("addendum section present");
358        assert_eq!(
359            addendum.agent0.as_deref(),
360            Some("addenda/0-personal.md"),
361            "addendum.agent0 pinned to addenda/0-personal.md"
362        );
363        let tuning = cfg.tuning.as_ref().expect("tuning section present");
364        assert_eq!(tuning.ticker_interval_s, Some(60));
365    }
366
367    #[test]
368    fn unsupported_schema_version_errors_loudly() {
369        let tmp = tempfile::tempdir().unwrap();
370        let path = tmp.path().join("netsky.toml");
371        write(&path, "schema_version = 99\n");
372        let err = Config::load_from(&path).expect_err("schema_version=99 should error");
373        let msg = err.to_string();
374        assert!(
375            msg.contains("schema_version 99"),
376            "error should name the bad version: {msg}"
377        );
378        assert!(
379            msg.contains("supports"),
380            "error should list supported versions: {msg}"
381        );
382    }
383
384    #[test]
385    fn malformed_toml_returns_err() {
386        let tmp = tempfile::tempdir().unwrap();
387        let path = tmp.path().join("netsky.toml");
388        write(&path, "this = is not [valid toml\n");
389        let err = Config::load_from(&path).expect_err("malformed should err");
390        assert!(
391            err.to_string().contains("parse"),
392            "error should mention parse failure: {err}"
393        );
394    }
395
396    #[test]
397    fn partial_toml_leaves_unset_sections_none() {
398        let tmp = tempfile::tempdir().unwrap();
399        let path = tmp.path().join("netsky.toml");
400        write(
401            &path,
402            r#"
403schema_version = 1
404[owner]
405name = "Alice"
406imessage = "+15551234567"
407"#,
408        );
409        let cfg = Config::load_from(&path).unwrap().expect("Some");
410        assert_eq!(cfg.owner.as_ref().unwrap().name.as_deref(), Some("Alice"));
411        assert!(cfg.tuning.is_none(), "[tuning] absent => None");
412        assert!(cfg.addendum.is_none(), "[addendum] absent => None");
413        assert!(cfg.peers.is_none(), "[peers] absent => None");
414    }
415
416    #[test]
417    fn resolve_prefers_env_over_default() {
418        let prior = std::env::var("NETSKY_TEST_RESOLVE").ok();
419        unsafe {
420            std::env::set_var("NETSKY_TEST_RESOLVE", "from-env");
421        }
422        let got = resolve("NETSKY_TEST_RESOLVE", |_| None, "from-default");
423        assert_eq!(got, "from-env");
424        unsafe {
425            match prior {
426                Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE", v),
427                None => std::env::remove_var("NETSKY_TEST_RESOLVE"),
428            }
429        }
430    }
431
432    #[test]
433    fn resolve_falls_through_to_default_when_env_and_toml_unset() {
434        let prior = std::env::var("NETSKY_TEST_RESOLVE_FT").ok();
435        unsafe {
436            std::env::remove_var("NETSKY_TEST_RESOLVE_FT");
437        }
438        let got = resolve("NETSKY_TEST_RESOLVE_FT", |_| None, "from-default");
439        assert_eq!(got, "from-default");
440        unsafe {
441            if let Some(v) = prior {
442                std::env::set_var("NETSKY_TEST_RESOLVE_FT", v);
443            }
444        }
445    }
446
447    #[test]
448    fn resolve_treats_empty_env_as_unset() {
449        let prior = std::env::var("NETSKY_TEST_RESOLVE_EMPTY").ok();
450        unsafe {
451            std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", "");
452        }
453        let got = resolve("NETSKY_TEST_RESOLVE_EMPTY", |_| None, "from-default");
454        assert_eq!(
455            got, "from-default",
456            "empty env should fall through to default, not return empty"
457        );
458        unsafe {
459            match prior {
460                Some(v) => std::env::set_var("NETSKY_TEST_RESOLVE_EMPTY", v),
461                None => std::env::remove_var("NETSKY_TEST_RESOLVE_EMPTY"),
462            }
463        }
464    }
465
466    #[test]
467    fn iroh_peers_keyed_by_label_via_serde_flatten() {
468        let tmp = tempfile::tempdir().unwrap();
469        let path = tmp.path().join("netsky.toml");
470        write(
471            &path,
472            r#"
473[peers.iroh]
474default_label = "personal"
475
476[peers.iroh.work]
477node_id = "abc123"
478created = "2026-04-15T04:30:00Z"
479notes = "work laptop"
480
481[peers.iroh.server]
482node_id = "def456"
483"#,
484        );
485        let cfg = Config::load_from(&path).unwrap().expect("Some");
486        let iroh = cfg.peers.as_ref().unwrap().iroh.as_ref().unwrap();
487        assert_eq!(iroh.default_label.as_deref(), Some("personal"));
488        assert_eq!(iroh.by_label.len(), 2);
489        assert_eq!(
490            iroh.by_label.get("work").unwrap().node_id.as_deref(),
491            Some("abc123")
492        );
493        assert_eq!(
494            iroh.by_label.get("server").unwrap().node_id.as_deref(),
495            Some("def456")
496        );
497    }
498}