Skip to main content

ninox_core/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, fs, path::PathBuf};
4
5use crate::harness::{HarnessRegistry, HarnessSpec};
6
7// ---------------------------------------------------------------------------
8// Theme
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ThemeVariant {
14    Light,
15    #[default]
16    Dark,
17    Ninox,
18}
19
20// ---------------------------------------------------------------------------
21// Agent configuration
22// ---------------------------------------------------------------------------
23
24/// Which agent harness and model to use for a session type.
25///
26/// Example `~/.config/ninox/config.toml`:
27/// ```toml
28/// [orchestrator]
29/// harness = "claude-code"
30/// model = "claude-opus-4-5"
31///
32/// [worker]
33/// harness = "codex"
34/// model = "gpt-4o"
35/// ```
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AgentConfig {
38    /// Agent harness: `"claude-code"`, `"codex"`, `"aider"`, or `"opencode"`.
39    #[serde(default = "default_harness")]
40    pub harness: String,
41    /// Model identifier passed to the harness CLI.
42    /// Omit to use the harness default.
43    pub model: Option<String>,
44}
45
46fn default_harness() -> String {
47    "claude-code".to_string()
48}
49
50impl Default for AgentConfig {
51    fn default() -> Self {
52        Self { harness: default_harness(), model: None }
53    }
54}
55
56// Launch-command construction lives in `crate::harness` — `AgentConfig` is
57// only the per-role/per-spawn pointer (harness name + model) into the
58// registry; resolve via `AppConfig::registry().interactive_cmd/worker_cmd`.
59
60// ---------------------------------------------------------------------------
61// Brain configuration
62// ---------------------------------------------------------------------------
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct BrainConfig {
66    pub path: Option<PathBuf>,
67    /// Additional named knowledge bases selectable when spawning an
68    /// orchestrator. The implicit "default" catalogue (this config's
69    /// `resolved_brain_path()`) is always offered first by
70    /// `AppConfig::catalogue_options()` and is not duplicated even if an
71    /// entry here is also named "default".
72    #[serde(default)]
73    pub catalogues: Vec<CatalogueRef>,
74}
75
76/// A named, selectable knowledge-base catalogue.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct CatalogueRef {
79    pub name: String,
80    pub path: PathBuf,
81}
82
83// ---------------------------------------------------------------------------
84// Brain harvest configuration
85// ---------------------------------------------------------------------------
86
87/// Opt-in-by-default background knowledge capture: when a worker session's
88/// PR is first detected, a short-lived `claude -p` subprocess reads its diff
89/// and writes facts into the brain vault. See `lifecycle::brain_harvest`.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct BrainHarvestConfig {
92    #[serde(default = "default_brain_harvest_enabled")]
93    pub enabled: bool,
94}
95
96fn default_brain_harvest_enabled() -> bool {
97    true
98}
99
100impl Default for BrainHarvestConfig {
101    fn default() -> Self {
102        Self { enabled: true }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Inbox messaging configuration
108// ---------------------------------------------------------------------------
109
110/// Opt-in (default OFF) file-based inbox for orchestrator↔worker messaging.
111///
112/// Off (default): `ninox send` and `Engine::send_to_session` behave exactly
113/// as before — the message is injected directly as verified keyboard input
114/// (`tmux::send_keys`, hardened in PR #69 with a pre-Enter delay and
115/// verify/retry).
116///
117/// On: the message is instead written durably to the target session's
118/// file-based inbox (`ninox_core::inbox`), drained by the Stop/
119/// UserPromptSubmit hooks installed in the worker's worktree settings (see
120/// `ninox_app::spawn_util::ensure_statusline_settings`) — keystrokes are
121/// then only a best-effort idle-wake nudge (`tmux::wake_idle_session`), not
122/// the message itself.
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct InboxMessagingConfig {
125    #[serde(default)]
126    pub enabled: bool,
127}
128
129// ---------------------------------------------------------------------------
130// Session retention configuration
131// ---------------------------------------------------------------------------
132
133/// How long a completed session record lingers after reaching a terminal
134/// state before the poller's retention sweep purges it — giving the fleet
135/// board a grace window to show what just finished instead of it vanishing
136/// the instant `Done`/`Terminated` is set. See
137/// `ninox_core::lifecycle::poller::Poller::sweep_retired_sessions`.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SessionRetentionConfig {
140    /// Days a `Done`/`Terminated` session stays queryable after reaching
141    /// that terminal state. Default: 2.
142    #[serde(default = "default_done_retention_days")]
143    pub done_retention_days: u64,
144}
145
146fn default_done_retention_days() -> u64 {
147    2
148}
149
150impl Default for SessionRetentionConfig {
151    fn default() -> Self {
152        Self { done_retention_days: default_done_retention_days() }
153    }
154}
155
156impl SessionRetentionConfig {
157    /// The retention window expressed in milliseconds, for comparison
158    /// against `Session::terminal_at` (Unix epoch milliseconds).
159    pub fn retention_millis(&self) -> i64 {
160        self.done_retention_days as i64 * 24 * 60 * 60 * 1000
161    }
162}
163
164// ---------------------------------------------------------------------------
165// App configuration
166// ---------------------------------------------------------------------------
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AppConfig {
170    pub port:      u16,
171    pub font_size: f32,
172    #[serde(default)]
173    pub theme:     ThemeVariant,
174    /// Override for the orchestrator root directory.
175    /// Defaults to `~/.config/ninox/orchestrator`.
176    #[serde(default)]
177    pub orchestrator_root: Option<PathBuf>,
178    /// Agent harness and model for orchestrator sessions.
179    #[serde(default)]
180    pub orchestrator: AgentConfig,
181    /// Agent harness and model for worker sessions spawned by `ninox spawn`.
182    #[serde(default)]
183    pub worker: AgentConfig,
184    /// GitHub personal access token. If absent, falls back to GITHUB_TOKEN env var.
185    /// Requires `repo` scope for private repos, `public_repo` for public.
186    #[serde(default)]
187    pub github_token: Option<String>,
188    /// Knowledge base (brain) configuration.
189    #[serde(default)]
190    pub brain: BrainConfig,
191    /// Background brain-harvest toggle. See `BrainHarvestConfig`.
192    #[serde(default)]
193    pub brain_harvest: BrainHarvestConfig,
194    /// Grace-period retention for completed session records — see
195    /// `SessionRetentionConfig`.
196    #[serde(default)]
197    pub session_retention: SessionRetentionConfig,
198    /// File-based inbox toggle for orchestrator↔worker messaging. Opt-in,
199    /// default off — see `InboxMessagingConfig`.
200    #[serde(default)]
201    pub inbox_messaging: InboxMessagingConfig,
202    /// Theme file name (resolves to `~/.config/ninox/themes/<name>.toml`) or
203    /// an absolute/`~`-relative path. `None` uses `themes/field-notes.toml`
204    /// if present, else the built-in Field Notes palettes.
205    #[serde(default)]
206    pub theme_file: Option<String>,
207    /// Agent-harness registry overrides/extensions (`[harnesses.<name>]`).
208    /// Builtin specs for claude-code/codex/opencode/aider/freebuff apply
209    /// when a name is absent here. See `crate::harness`. Kept last so TOML
210    /// serialization emits this table-of-tables after every scalar field.
211    #[serde(default)]
212    pub harnesses: BTreeMap<String, HarnessSpec>,
213}
214
215impl Default for AppConfig {
216    fn default() -> Self {
217        Self {
218            port:             8080,
219            font_size:        13.0,
220            theme:            ThemeVariant::Dark,
221            orchestrator_root: None,
222            orchestrator:     AgentConfig::default(),
223            worker:           AgentConfig::default(),
224            github_token:     None,
225            brain:            BrainConfig::default(),
226            brain_harvest:    BrainHarvestConfig::default(),
227            session_retention: SessionRetentionConfig::default(),
228            theme_file:       None,
229            harnesses:        BTreeMap::new(),
230            inbox_messaging:  InboxMessagingConfig::default(),
231        }
232    }
233}
234
235impl AppConfig {
236    /// The effective harness registry: builtin specs overlaid by this
237    /// config's `[harnesses.*]` entries.
238    pub fn registry(&self) -> HarnessRegistry {
239        HarnessRegistry::from_config(&self.harnesses)
240    }
241
242    /// Path to the knowledge-base (brain) directory.
243    ///
244    /// Honors the `NINOX_BRAIN` environment variable as an override: if
245    /// set, it is treated as an absolute path to the brain directory and
246    /// returned as-is, mirroring how `config_path()` honors `NINOX_CONFIG`.
247    /// This lets a selected catalogue (see `catalogue_options()`) be handed
248    /// to a spawned orchestrator session via its environment without
249    /// mutating `config.toml`, and lets tests redirect brain reads/writes
250    /// without touching the real user brain directory.
251    ///
252    /// Falls back to `self.brain.path` when set, else `<config_dir>/ninox/brain`.
253    pub fn resolved_brain_path(&self) -> PathBuf {
254        if let Ok(p) = std::env::var("NINOX_BRAIN") {
255            if !p.is_empty() {
256                return PathBuf::from(p);
257            }
258        }
259        if let Some(ref p) = self.brain.path {
260            return p.clone();
261        }
262        dirs::config_dir()
263            .unwrap_or_else(|| PathBuf::from("."))
264            .join("ninox")
265            .join("brain")
266    }
267
268    /// All selectable knowledge-base catalogues: the implicit "default"
269    /// (this config's `resolved_brain_path()`) followed by any additional
270    /// catalogues configured under `[[brain.catalogues]]` — skipping any
271    /// entry literally named "default" to avoid a confusing duplicate.
272    pub fn catalogue_options(&self) -> Vec<CatalogueRef> {
273        let mut options = vec![CatalogueRef {
274            name: "default".to_string(),
275            path: self.resolved_brain_path(),
276        }];
277        options.extend(
278            self.brain
279                .catalogues
280                .iter()
281                .filter(|c| c.name != "default")
282                .cloned(),
283        );
284        options
285    }
286
287    pub fn resolved_orchestrator_root(&self) -> PathBuf {
288        self.orchestrator_root.clone().unwrap_or_else(|| {
289            dirs::config_dir()
290                .unwrap_or_else(|| PathBuf::from("."))
291                .join("ninox")
292                .join("orchestrator")
293        })
294    }
295
296    /// Path to the `config.toml` file.
297    ///
298    /// Honors the `NINOX_CONFIG` environment variable as an override: if
299    /// set, it is treated as an absolute path to the config file itself
300    /// (not a directory) and returned as-is. This is the same override
301    /// consumed by spawned agent sessions (see the `NINOX_CONFIG` env var
302    /// set alongside `NINOX_BIN` when launching orchestrator sessions), and
303    /// it also lets tests redirect config reads/writes away from the real
304    /// user config file (e.g. `~/Library/Application Support/ninox/config.toml`
305    /// on macOS) without mutating developer machine state.
306    ///
307    /// Falls back to `<config_dir>/ninox/config.toml` when unset.
308    pub fn config_path() -> PathBuf {
309        if let Ok(p) = std::env::var("NINOX_CONFIG") {
310            if !p.is_empty() {
311                return PathBuf::from(p);
312            }
313        }
314        dirs::config_dir()
315            .unwrap_or_else(|| PathBuf::from("."))
316            .join("ninox")
317            .join("config.toml")
318    }
319
320    /// Directory for Ninox-managed shell wrappers prepended to agent PATH.
321    /// Default: `~/.config/ninox/bin/`
322    pub fn ninox_bin_dir() -> PathBuf {
323        dirs::config_dir()
324            .unwrap_or_else(|| PathBuf::from("."))
325            .join("ninox")
326            .join("bin")
327    }
328
329    /// Directory where per-session metadata JSON files are written by wrapper hooks.
330    /// Default: `~/.config/ninox/sessions/`
331    pub fn sessions_dir() -> PathBuf {
332        dirs::config_dir()
333            .unwrap_or_else(|| PathBuf::from("."))
334            .join("ninox")
335            .join("sessions")
336    }
337
338    fn path() -> PathBuf { Self::config_path() }
339
340    pub fn load() -> Result<Self> {
341        let p = Self::path();
342        if !p.exists() { return Ok(Self::default()); }
343        Ok(toml::from_str(&fs::read_to_string(p)?)?)
344    }
345
346    pub fn save(&self) -> Result<()> {
347        let p = Self::path();
348        fs::create_dir_all(p.parent().unwrap())?;
349        fs::write(p, toml::to_string(self)?)?;
350        Ok(())
351    }
352}
353
354/// Serializes tests that mutate process-global env vars (`NINOX_CONFIG`,
355/// `NINOX_BRAIN`) against each other — `cargo test` runs test fns on
356/// parallel threads, so without this guard one test's env mutation could
357/// leak into another's read. `pub(crate)` and shared with
358/// `lifecycle::poller`'s tests, which also mutate `NINOX_CONFIG`.
359#[cfg(test)]
360pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
361
362/// Set `key=value` for the duration of `f`, restoring the prior value (or
363/// unsetting it) afterward. Serialized via `ENV_TEST_GUARD` since env vars
364/// are process-global state shared across parallel test threads. Mirrors
365/// `ninox_app::app::tests::with_env_override`.
366#[cfg(test)]
367pub(crate) fn with_env_override<T>(
368    key: &str,
369    value: impl AsRef<std::ffi::OsStr>,
370    f: impl FnOnce() -> T,
371) -> T {
372    let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
373    let prior = std::env::var(key).ok();
374    std::env::set_var(key, value);
375
376    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
377
378    match prior {
379        Some(v) => std::env::set_var(key, v),
380        None    => std::env::remove_var(key),
381    }
382    result.unwrap()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use tempfile::tempdir;
389
390    #[test]
391    fn round_trip() {
392        let dir = tempdir().unwrap();
393        let path = dir.path().join("config.toml");
394        let cfg = AppConfig { port: 9090, font_size: 14.0, theme: ThemeVariant::Light, ..AppConfig::default() };
395        fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
396        let loaded: AppConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
397        assert_eq!(loaded.port, 9090);
398        assert_eq!(loaded.theme, ThemeVariant::Light);
399        assert!(loaded.orchestrator_root.is_none());
400    }
401
402    #[test]
403    fn default_theme_is_dark() {
404        assert_eq!(AppConfig::default().theme, ThemeVariant::Dark);
405    }
406
407    #[test]
408    fn session_retention_defaults_to_two_days() {
409        let cfg = SessionRetentionConfig::default();
410        assert_eq!(cfg.done_retention_days, 2);
411        assert_eq!(cfg.retention_millis(), 2 * 24 * 60 * 60 * 1000);
412    }
413
414    #[test]
415    fn missing_session_retention_field_defaults() {
416        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
417        assert_eq!(cfg.session_retention.done_retention_days, 2);
418    }
419
420    #[test]
421    fn missing_theme_field_defaults_to_dark() {
422        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
423        assert_eq!(cfg.theme, ThemeVariant::Dark);
424    }
425
426    #[test]
427    fn agent_config_round_trip() {
428        let toml = "port = 8080\nfont_size = 13.0\n\n[orchestrator]\nharness = \"claude-code\"\nmodel = \"claude-opus-4-5\"\n\n[worker]\nharness = \"codex\"\n";
429        let cfg: AppConfig = toml::from_str(toml).unwrap();
430        assert_eq!(cfg.orchestrator.harness, "claude-code");
431        assert_eq!(cfg.orchestrator.model.as_deref(), Some("claude-opus-4-5"));
432        assert_eq!(cfg.worker.harness, "codex");
433        assert!(cfg.worker.model.is_none());
434    }
435
436    // Launch-shape tests for the four known harnesses moved to
437    // `crate::harness::tests` with the registry.
438
439    #[test]
440    fn resolved_orchestrator_root_default() {
441        let cfg = AppConfig::default();
442        assert!(cfg.resolved_orchestrator_root().ends_with("ninox/orchestrator"));
443    }
444
445    #[test]
446    fn config_path_honors_ninox_config_env() {
447        let dir = tempdir().unwrap();
448        let override_path = dir.path().join("config_path_honors_ninox_config_env.toml");
449
450        with_env_override("NINOX_CONFIG", &override_path, || {
451            assert_eq!(AppConfig::config_path(), override_path);
452        });
453    }
454
455    #[test]
456    fn resolved_brain_path_honors_ninox_brain_env() {
457        let dir = tempdir().unwrap();
458        let override_path = dir.path().join("brain-override");
459
460        with_env_override("NINOX_BRAIN", &override_path, || {
461            let cfg = AppConfig::default();
462            assert_eq!(cfg.resolved_brain_path(), override_path);
463        });
464    }
465
466    #[test]
467    fn catalogue_options_defaults_to_single_entry() {
468        // Serialize against resolved_brain_path_honors_ninox_brain_env: this
469        // test reads resolved_brain_path() twice (via catalogue_options and
470        // directly) and must not straddle that test's NINOX_BRAIN window.
471        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
472        let cfg = AppConfig::default();
473        let options = cfg.catalogue_options();
474        assert_eq!(options.len(), 1);
475        assert_eq!(options[0].name, "default");
476        assert_eq!(options[0].path, cfg.resolved_brain_path());
477    }
478
479    #[test]
480    fn brain_harvest_defaults_to_enabled() {
481        assert!(AppConfig::default().brain_harvest.enabled);
482    }
483
484    #[test]
485    fn brain_harvest_missing_table_defaults_to_enabled() {
486        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
487        assert!(cfg.brain_harvest.enabled);
488    }
489
490    #[test]
491    fn brain_harvest_can_be_disabled_via_config() {
492        let toml_src = "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n";
493        let cfg: AppConfig = toml::from_str(toml_src).unwrap();
494        assert!(!cfg.brain_harvest.enabled);
495    }
496
497    #[test]
498    fn inbox_messaging_defaults_to_disabled() {
499        assert!(!AppConfig::default().inbox_messaging.enabled);
500    }
501
502    #[test]
503    fn inbox_messaging_missing_table_defaults_to_disabled() {
504        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
505        assert!(!cfg.inbox_messaging.enabled);
506    }
507
508    #[test]
509    fn inbox_messaging_can_be_enabled_via_config() {
510        let toml_src = "port = 8080\nfont_size = 13.0\n\n[inbox_messaging]\nenabled = true\n";
511        let cfg: AppConfig = toml::from_str(toml_src).unwrap();
512        assert!(cfg.inbox_messaging.enabled);
513    }
514
515    #[test]
516    fn catalogue_options_appends_configured_catalogues_and_skips_duplicate_default() {
517        let mut cfg = AppConfig::default();
518        cfg.brain.catalogues = vec![
519            CatalogueRef { name: "docs".to_string(), path: PathBuf::from("/tmp/docs-brain") },
520            CatalogueRef { name: "default".to_string(), path: PathBuf::from("/tmp/should-be-skipped") },
521        ];
522        let options = cfg.catalogue_options();
523        assert_eq!(options.len(), 2);
524        assert_eq!(options[0].name, "default");
525        assert_eq!(options[1].name, "docs");
526        assert_eq!(options[1].path, PathBuf::from("/tmp/docs-brain"));
527    }
528}