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// Session retention configuration
108// ---------------------------------------------------------------------------
109
110/// How long a completed session record lingers after reaching a terminal
111/// state before the poller's retention sweep purges it — giving the fleet
112/// board a grace window to show what just finished instead of it vanishing
113/// the instant `Done`/`Terminated` is set. See
114/// `ninox_core::lifecycle::poller::Poller::sweep_retired_sessions`.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SessionRetentionConfig {
117    /// Days a `Done`/`Terminated` session stays queryable after reaching
118    /// that terminal state. Default: 2.
119    #[serde(default = "default_done_retention_days")]
120    pub done_retention_days: u64,
121}
122
123fn default_done_retention_days() -> u64 {
124    2
125}
126
127impl Default for SessionRetentionConfig {
128    fn default() -> Self {
129        Self { done_retention_days: default_done_retention_days() }
130    }
131}
132
133impl SessionRetentionConfig {
134    /// The retention window expressed in milliseconds, for comparison
135    /// against `Session::terminal_at` (Unix epoch milliseconds).
136    pub fn retention_millis(&self) -> i64 {
137        self.done_retention_days as i64 * 24 * 60 * 60 * 1000
138    }
139}
140
141// ---------------------------------------------------------------------------
142// App configuration
143// ---------------------------------------------------------------------------
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct AppConfig {
147    pub port:      u16,
148    pub font_size: f32,
149    #[serde(default)]
150    pub theme:     ThemeVariant,
151    /// Override for the orchestrator root directory.
152    /// Defaults to `~/.config/ninox/orchestrator`.
153    #[serde(default)]
154    pub orchestrator_root: Option<PathBuf>,
155    /// Agent harness and model for orchestrator sessions.
156    #[serde(default)]
157    pub orchestrator: AgentConfig,
158    /// Agent harness and model for worker sessions spawned by `ninox spawn`.
159    #[serde(default)]
160    pub worker: AgentConfig,
161    /// GitHub personal access token. If absent, falls back to GITHUB_TOKEN env var.
162    /// Requires `repo` scope for private repos, `public_repo` for public.
163    #[serde(default)]
164    pub github_token: Option<String>,
165    /// Knowledge base (brain) configuration.
166    #[serde(default)]
167    pub brain: BrainConfig,
168    /// Background brain-harvest toggle. See `BrainHarvestConfig`.
169    #[serde(default)]
170    pub brain_harvest: BrainHarvestConfig,
171    /// Grace-period retention for completed session records — see
172    /// `SessionRetentionConfig`.
173    #[serde(default)]
174    pub session_retention: SessionRetentionConfig,
175    /// Theme file name (resolves to `~/.config/ninox/themes/<name>.toml`) or
176    /// an absolute/`~`-relative path. `None` uses `themes/field-notes.toml`
177    /// if present, else the built-in Field Notes palettes.
178    #[serde(default)]
179    pub theme_file: Option<String>,
180    /// Agent-harness registry overrides/extensions (`[harnesses.<name>]`).
181    /// Builtin specs for claude-code/codex/opencode/aider/freebuff apply
182    /// when a name is absent here. See `crate::harness`. Kept last so TOML
183    /// serialization emits this table-of-tables after every scalar field.
184    #[serde(default)]
185    pub harnesses: BTreeMap<String, HarnessSpec>,
186}
187
188impl Default for AppConfig {
189    fn default() -> Self {
190        Self {
191            port:             8080,
192            font_size:        13.0,
193            theme:            ThemeVariant::Dark,
194            orchestrator_root: None,
195            orchestrator:     AgentConfig::default(),
196            worker:           AgentConfig::default(),
197            github_token:     None,
198            brain:            BrainConfig::default(),
199            brain_harvest:    BrainHarvestConfig::default(),
200            session_retention: SessionRetentionConfig::default(),
201            theme_file:       None,
202            harnesses:        BTreeMap::new(),
203        }
204    }
205}
206
207impl AppConfig {
208    /// The effective harness registry: builtin specs overlaid by this
209    /// config's `[harnesses.*]` entries.
210    pub fn registry(&self) -> HarnessRegistry {
211        HarnessRegistry::from_config(&self.harnesses)
212    }
213
214    /// Path to the knowledge-base (brain) directory.
215    ///
216    /// Honors the `NINOX_BRAIN` environment variable as an override: if
217    /// set, it is treated as an absolute path to the brain directory and
218    /// returned as-is, mirroring how `config_path()` honors `NINOX_CONFIG`.
219    /// This lets a selected catalogue (see `catalogue_options()`) be handed
220    /// to a spawned orchestrator session via its environment without
221    /// mutating `config.toml`, and lets tests redirect brain reads/writes
222    /// without touching the real user brain directory.
223    ///
224    /// Falls back to `self.brain.path` when set, else `<config_dir>/ninox/brain`.
225    pub fn resolved_brain_path(&self) -> PathBuf {
226        if let Ok(p) = std::env::var("NINOX_BRAIN") {
227            if !p.is_empty() {
228                return PathBuf::from(p);
229            }
230        }
231        if let Some(ref p) = self.brain.path {
232            return p.clone();
233        }
234        dirs::config_dir()
235            .unwrap_or_else(|| PathBuf::from("."))
236            .join("ninox")
237            .join("brain")
238    }
239
240    /// All selectable knowledge-base catalogues: the implicit "default"
241    /// (this config's `resolved_brain_path()`) followed by any additional
242    /// catalogues configured under `[[brain.catalogues]]` — skipping any
243    /// entry literally named "default" to avoid a confusing duplicate.
244    pub fn catalogue_options(&self) -> Vec<CatalogueRef> {
245        let mut options = vec![CatalogueRef {
246            name: "default".to_string(),
247            path: self.resolved_brain_path(),
248        }];
249        options.extend(
250            self.brain
251                .catalogues
252                .iter()
253                .filter(|c| c.name != "default")
254                .cloned(),
255        );
256        options
257    }
258
259    pub fn resolved_orchestrator_root(&self) -> PathBuf {
260        self.orchestrator_root.clone().unwrap_or_else(|| {
261            dirs::config_dir()
262                .unwrap_or_else(|| PathBuf::from("."))
263                .join("ninox")
264                .join("orchestrator")
265        })
266    }
267
268    /// Path to the `config.toml` file.
269    ///
270    /// Honors the `NINOX_CONFIG` environment variable as an override: if
271    /// set, it is treated as an absolute path to the config file itself
272    /// (not a directory) and returned as-is. This is the same override
273    /// consumed by spawned agent sessions (see the `NINOX_CONFIG` env var
274    /// set alongside `NINOX_BIN` when launching orchestrator sessions), and
275    /// it also lets tests redirect config reads/writes away from the real
276    /// user config file (e.g. `~/Library/Application Support/ninox/config.toml`
277    /// on macOS) without mutating developer machine state.
278    ///
279    /// Falls back to `<config_dir>/ninox/config.toml` when unset.
280    pub fn config_path() -> PathBuf {
281        if let Ok(p) = std::env::var("NINOX_CONFIG") {
282            if !p.is_empty() {
283                return PathBuf::from(p);
284            }
285        }
286        dirs::config_dir()
287            .unwrap_or_else(|| PathBuf::from("."))
288            .join("ninox")
289            .join("config.toml")
290    }
291
292    /// Directory for Ninox-managed shell wrappers prepended to agent PATH.
293    /// Default: `~/.config/ninox/bin/`
294    pub fn ninox_bin_dir() -> PathBuf {
295        dirs::config_dir()
296            .unwrap_or_else(|| PathBuf::from("."))
297            .join("ninox")
298            .join("bin")
299    }
300
301    /// Directory where per-session metadata JSON files are written by wrapper hooks.
302    /// Default: `~/.config/ninox/sessions/`
303    pub fn sessions_dir() -> PathBuf {
304        dirs::config_dir()
305            .unwrap_or_else(|| PathBuf::from("."))
306            .join("ninox")
307            .join("sessions")
308    }
309
310    fn path() -> PathBuf { Self::config_path() }
311
312    pub fn load() -> Result<Self> {
313        let p = Self::path();
314        if !p.exists() { return Ok(Self::default()); }
315        Ok(toml::from_str(&fs::read_to_string(p)?)?)
316    }
317
318    pub fn save(&self) -> Result<()> {
319        let p = Self::path();
320        fs::create_dir_all(p.parent().unwrap())?;
321        fs::write(p, toml::to_string(self)?)?;
322        Ok(())
323    }
324}
325
326/// Serializes tests that mutate process-global env vars (`NINOX_CONFIG`,
327/// `NINOX_BRAIN`) against each other — `cargo test` runs test fns on
328/// parallel threads, so without this guard one test's env mutation could
329/// leak into another's read. `pub(crate)` and shared with
330/// `lifecycle::poller`'s tests, which also mutate `NINOX_CONFIG`.
331#[cfg(test)]
332pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
333
334/// Set `key=value` for the duration of `f`, restoring the prior value (or
335/// unsetting it) afterward. Serialized via `ENV_TEST_GUARD` since env vars
336/// are process-global state shared across parallel test threads. Mirrors
337/// `ninox_app::app::tests::with_env_override`.
338#[cfg(test)]
339pub(crate) fn with_env_override<T>(
340    key: &str,
341    value: impl AsRef<std::ffi::OsStr>,
342    f: impl FnOnce() -> T,
343) -> T {
344    let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
345    let prior = std::env::var(key).ok();
346    std::env::set_var(key, value);
347
348    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
349
350    match prior {
351        Some(v) => std::env::set_var(key, v),
352        None    => std::env::remove_var(key),
353    }
354    result.unwrap()
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use tempfile::tempdir;
361
362    #[test]
363    fn round_trip() {
364        let dir = tempdir().unwrap();
365        let path = dir.path().join("config.toml");
366        let cfg = AppConfig { port: 9090, font_size: 14.0, theme: ThemeVariant::Light, ..AppConfig::default() };
367        fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
368        let loaded: AppConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
369        assert_eq!(loaded.port, 9090);
370        assert_eq!(loaded.theme, ThemeVariant::Light);
371        assert!(loaded.orchestrator_root.is_none());
372    }
373
374    #[test]
375    fn default_theme_is_dark() {
376        assert_eq!(AppConfig::default().theme, ThemeVariant::Dark);
377    }
378
379    #[test]
380    fn session_retention_defaults_to_two_days() {
381        let cfg = SessionRetentionConfig::default();
382        assert_eq!(cfg.done_retention_days, 2);
383        assert_eq!(cfg.retention_millis(), 2 * 24 * 60 * 60 * 1000);
384    }
385
386    #[test]
387    fn missing_session_retention_field_defaults() {
388        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
389        assert_eq!(cfg.session_retention.done_retention_days, 2);
390    }
391
392    #[test]
393    fn missing_theme_field_defaults_to_dark() {
394        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
395        assert_eq!(cfg.theme, ThemeVariant::Dark);
396    }
397
398    #[test]
399    fn agent_config_round_trip() {
400        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";
401        let cfg: AppConfig = toml::from_str(toml).unwrap();
402        assert_eq!(cfg.orchestrator.harness, "claude-code");
403        assert_eq!(cfg.orchestrator.model.as_deref(), Some("claude-opus-4-5"));
404        assert_eq!(cfg.worker.harness, "codex");
405        assert!(cfg.worker.model.is_none());
406    }
407
408    // Launch-shape tests for the four known harnesses moved to
409    // `crate::harness::tests` with the registry.
410
411    #[test]
412    fn resolved_orchestrator_root_default() {
413        let cfg = AppConfig::default();
414        assert!(cfg.resolved_orchestrator_root().ends_with("ninox/orchestrator"));
415    }
416
417    #[test]
418    fn config_path_honors_ninox_config_env() {
419        let dir = tempdir().unwrap();
420        let override_path = dir.path().join("config_path_honors_ninox_config_env.toml");
421
422        with_env_override("NINOX_CONFIG", &override_path, || {
423            assert_eq!(AppConfig::config_path(), override_path);
424        });
425    }
426
427    #[test]
428    fn resolved_brain_path_honors_ninox_brain_env() {
429        let dir = tempdir().unwrap();
430        let override_path = dir.path().join("brain-override");
431
432        with_env_override("NINOX_BRAIN", &override_path, || {
433            let cfg = AppConfig::default();
434            assert_eq!(cfg.resolved_brain_path(), override_path);
435        });
436    }
437
438    #[test]
439    fn catalogue_options_defaults_to_single_entry() {
440        // Serialize against resolved_brain_path_honors_ninox_brain_env: this
441        // test reads resolved_brain_path() twice (via catalogue_options and
442        // directly) and must not straddle that test's NINOX_BRAIN window.
443        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
444        let cfg = AppConfig::default();
445        let options = cfg.catalogue_options();
446        assert_eq!(options.len(), 1);
447        assert_eq!(options[0].name, "default");
448        assert_eq!(options[0].path, cfg.resolved_brain_path());
449    }
450
451    #[test]
452    fn brain_harvest_defaults_to_enabled() {
453        assert!(AppConfig::default().brain_harvest.enabled);
454    }
455
456    #[test]
457    fn brain_harvest_missing_table_defaults_to_enabled() {
458        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
459        assert!(cfg.brain_harvest.enabled);
460    }
461
462    #[test]
463    fn brain_harvest_can_be_disabled_via_config() {
464        let toml_src = "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n";
465        let cfg: AppConfig = toml::from_str(toml_src).unwrap();
466        assert!(!cfg.brain_harvest.enabled);
467    }
468
469    #[test]
470    fn catalogue_options_appends_configured_catalogues_and_skips_duplicate_default() {
471        let mut cfg = AppConfig::default();
472        cfg.brain.catalogues = vec![
473            CatalogueRef { name: "docs".to_string(), path: PathBuf::from("/tmp/docs-brain") },
474            CatalogueRef { name: "default".to_string(), path: PathBuf::from("/tmp/should-be-skipped") },
475        ];
476        let options = cfg.catalogue_options();
477        assert_eq!(options.len(), 2);
478        assert_eq!(options[0].name, "default");
479        assert_eq!(options[1].name, "docs");
480        assert_eq!(options[1].path, PathBuf::from("/tmp/docs-brain"));
481    }
482}