1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, fs, path::PathBuf};
4
5use crate::harness::{HarnessRegistry, HarnessSpec};
6
7#[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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AgentConfig {
38 #[serde(default = "default_harness")]
40 pub harness: String,
41 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct BrainConfig {
66 pub path: Option<PathBuf>,
67 #[serde(default)]
73 pub catalogues: Vec<CatalogueRef>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct CatalogueRef {
79 pub name: String,
80 pub path: PathBuf,
81}
82
83#[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#[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 #[serde(default)]
119 pub orchestrator_root: Option<PathBuf>,
120 #[serde(default)]
122 pub orchestrator: AgentConfig,
123 #[serde(default)]
125 pub worker: AgentConfig,
126 #[serde(default)]
129 pub github_token: Option<String>,
130 #[serde(default)]
132 pub brain: BrainConfig,
133 #[serde(default)]
135 pub brain_harvest: BrainHarvestConfig,
136 #[serde(default)]
140 pub theme_file: Option<String>,
141 #[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 pub fn registry(&self) -> HarnessRegistry {
171 HarnessRegistry::from_config(&self.harnesses)
172 }
173
174 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 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 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 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 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#[cfg(test)]
292pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
293
294#[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 #[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 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}