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.
293pub 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        // The repo-root netsky.toml.example IS the schema documentation;
333        // make sure it parses cleanly so it stays trustworthy. This
334        // also catches future schema additions where someone forgets
335        // to add a struct field.
336        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        // Pin a few fields to catch regression if the example drifts.
346        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}