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