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)]
116pub struct SessionRetentionConfig {
117 #[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 pub fn retention_millis(&self) -> i64 {
137 self.done_retention_days as i64 * 24 * 60 * 60 * 1000
138 }
139}
140
141#[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 #[serde(default)]
154 pub orchestrator_root: Option<PathBuf>,
155 #[serde(default)]
157 pub orchestrator: AgentConfig,
158 #[serde(default)]
160 pub worker: AgentConfig,
161 #[serde(default)]
164 pub github_token: Option<String>,
165 #[serde(default)]
167 pub brain: BrainConfig,
168 #[serde(default)]
170 pub brain_harvest: BrainHarvestConfig,
171 #[serde(default)]
174 pub session_retention: SessionRetentionConfig,
175 #[serde(default)]
179 pub theme_file: Option<String>,
180 #[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 pub fn registry(&self) -> HarnessRegistry {
211 HarnessRegistry::from_config(&self.harnesses)
212 }
213
214 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 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 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 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 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#[cfg(test)]
332pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
333
334#[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 #[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 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}