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