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, Default, Serialize, Deserialize)]
124pub struct InboxMessagingConfig {
125 #[serde(default)]
126 pub enabled: bool,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SessionRetentionConfig {
140 #[serde(default = "default_done_retention_days")]
143 pub done_retention_days: u64,
144}
145
146fn default_done_retention_days() -> u64 {
147 2
148}
149
150impl Default for SessionRetentionConfig {
151 fn default() -> Self {
152 Self { done_retention_days: default_done_retention_days() }
153 }
154}
155
156impl SessionRetentionConfig {
157 pub fn retention_millis(&self) -> i64 {
160 self.done_retention_days as i64 * 24 * 60 * 60 * 1000
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AppConfig {
170 pub port: u16,
171 pub font_size: f32,
172 #[serde(default)]
173 pub theme: ThemeVariant,
174 #[serde(default)]
177 pub orchestrator_root: Option<PathBuf>,
178 #[serde(default)]
180 pub orchestrator: AgentConfig,
181 #[serde(default)]
183 pub worker: AgentConfig,
184 #[serde(default)]
187 pub github_token: Option<String>,
188 #[serde(default)]
190 pub brain: BrainConfig,
191 #[serde(default)]
193 pub brain_harvest: BrainHarvestConfig,
194 #[serde(default)]
197 pub session_retention: SessionRetentionConfig,
198 #[serde(default)]
201 pub inbox_messaging: InboxMessagingConfig,
202 #[serde(default)]
206 pub theme_file: Option<String>,
207 #[serde(default)]
212 pub harnesses: BTreeMap<String, HarnessSpec>,
213}
214
215impl Default for AppConfig {
216 fn default() -> Self {
217 Self {
218 port: 8080,
219 font_size: 13.0,
220 theme: ThemeVariant::Dark,
221 orchestrator_root: None,
222 orchestrator: AgentConfig::default(),
223 worker: AgentConfig::default(),
224 github_token: None,
225 brain: BrainConfig::default(),
226 brain_harvest: BrainHarvestConfig::default(),
227 session_retention: SessionRetentionConfig::default(),
228 theme_file: None,
229 harnesses: BTreeMap::new(),
230 inbox_messaging: InboxMessagingConfig::default(),
231 }
232 }
233}
234
235impl AppConfig {
236 pub fn registry(&self) -> HarnessRegistry {
239 HarnessRegistry::from_config(&self.harnesses)
240 }
241
242 pub fn resolved_brain_path(&self) -> PathBuf {
254 if let Ok(p) = std::env::var("NINOX_BRAIN") {
255 if !p.is_empty() {
256 return PathBuf::from(p);
257 }
258 }
259 if let Some(ref p) = self.brain.path {
260 return p.clone();
261 }
262 dirs::config_dir()
263 .unwrap_or_else(|| PathBuf::from("."))
264 .join("ninox")
265 .join("brain")
266 }
267
268 pub fn catalogue_options(&self) -> Vec<CatalogueRef> {
273 let mut options = vec![CatalogueRef {
274 name: "default".to_string(),
275 path: self.resolved_brain_path(),
276 }];
277 options.extend(
278 self.brain
279 .catalogues
280 .iter()
281 .filter(|c| c.name != "default")
282 .cloned(),
283 );
284 options
285 }
286
287 pub fn resolved_orchestrator_root(&self) -> PathBuf {
288 self.orchestrator_root.clone().unwrap_or_else(|| {
289 dirs::config_dir()
290 .unwrap_or_else(|| PathBuf::from("."))
291 .join("ninox")
292 .join("orchestrator")
293 })
294 }
295
296 pub fn config_path() -> PathBuf {
309 if let Ok(p) = std::env::var("NINOX_CONFIG") {
310 if !p.is_empty() {
311 return PathBuf::from(p);
312 }
313 }
314 dirs::config_dir()
315 .unwrap_or_else(|| PathBuf::from("."))
316 .join("ninox")
317 .join("config.toml")
318 }
319
320 pub fn ninox_bin_dir() -> PathBuf {
323 dirs::config_dir()
324 .unwrap_or_else(|| PathBuf::from("."))
325 .join("ninox")
326 .join("bin")
327 }
328
329 pub fn sessions_dir() -> PathBuf {
332 dirs::config_dir()
333 .unwrap_or_else(|| PathBuf::from("."))
334 .join("ninox")
335 .join("sessions")
336 }
337
338 fn path() -> PathBuf { Self::config_path() }
339
340 pub fn load() -> Result<Self> {
341 let p = Self::path();
342 if !p.exists() { return Ok(Self::default()); }
343 Ok(toml::from_str(&fs::read_to_string(p)?)?)
344 }
345
346 pub fn save(&self) -> Result<()> {
347 let p = Self::path();
348 fs::create_dir_all(p.parent().unwrap())?;
349 fs::write(p, toml::to_string(self)?)?;
350 Ok(())
351 }
352}
353
354#[cfg(test)]
360pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
361
362#[cfg(test)]
367pub(crate) fn with_env_override<T>(
368 key: &str,
369 value: impl AsRef<std::ffi::OsStr>,
370 f: impl FnOnce() -> T,
371) -> T {
372 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
373 let prior = std::env::var(key).ok();
374 std::env::set_var(key, value);
375
376 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
377
378 match prior {
379 Some(v) => std::env::set_var(key, v),
380 None => std::env::remove_var(key),
381 }
382 result.unwrap()
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use tempfile::tempdir;
389
390 #[test]
391 fn round_trip() {
392 let dir = tempdir().unwrap();
393 let path = dir.path().join("config.toml");
394 let cfg = AppConfig { port: 9090, font_size: 14.0, theme: ThemeVariant::Light, ..AppConfig::default() };
395 fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
396 let loaded: AppConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
397 assert_eq!(loaded.port, 9090);
398 assert_eq!(loaded.theme, ThemeVariant::Light);
399 assert!(loaded.orchestrator_root.is_none());
400 }
401
402 #[test]
403 fn default_theme_is_dark() {
404 assert_eq!(AppConfig::default().theme, ThemeVariant::Dark);
405 }
406
407 #[test]
408 fn session_retention_defaults_to_two_days() {
409 let cfg = SessionRetentionConfig::default();
410 assert_eq!(cfg.done_retention_days, 2);
411 assert_eq!(cfg.retention_millis(), 2 * 24 * 60 * 60 * 1000);
412 }
413
414 #[test]
415 fn missing_session_retention_field_defaults() {
416 let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
417 assert_eq!(cfg.session_retention.done_retention_days, 2);
418 }
419
420 #[test]
421 fn missing_theme_field_defaults_to_dark() {
422 let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
423 assert_eq!(cfg.theme, ThemeVariant::Dark);
424 }
425
426 #[test]
427 fn agent_config_round_trip() {
428 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";
429 let cfg: AppConfig = toml::from_str(toml).unwrap();
430 assert_eq!(cfg.orchestrator.harness, "claude-code");
431 assert_eq!(cfg.orchestrator.model.as_deref(), Some("claude-opus-4-5"));
432 assert_eq!(cfg.worker.harness, "codex");
433 assert!(cfg.worker.model.is_none());
434 }
435
436 #[test]
440 fn resolved_orchestrator_root_default() {
441 let cfg = AppConfig::default();
442 assert!(cfg.resolved_orchestrator_root().ends_with("ninox/orchestrator"));
443 }
444
445 #[test]
446 fn config_path_honors_ninox_config_env() {
447 let dir = tempdir().unwrap();
448 let override_path = dir.path().join("config_path_honors_ninox_config_env.toml");
449
450 with_env_override("NINOX_CONFIG", &override_path, || {
451 assert_eq!(AppConfig::config_path(), override_path);
452 });
453 }
454
455 #[test]
456 fn resolved_brain_path_honors_ninox_brain_env() {
457 let dir = tempdir().unwrap();
458 let override_path = dir.path().join("brain-override");
459
460 with_env_override("NINOX_BRAIN", &override_path, || {
461 let cfg = AppConfig::default();
462 assert_eq!(cfg.resolved_brain_path(), override_path);
463 });
464 }
465
466 #[test]
467 fn catalogue_options_defaults_to_single_entry() {
468 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
472 let cfg = AppConfig::default();
473 let options = cfg.catalogue_options();
474 assert_eq!(options.len(), 1);
475 assert_eq!(options[0].name, "default");
476 assert_eq!(options[0].path, cfg.resolved_brain_path());
477 }
478
479 #[test]
480 fn brain_harvest_defaults_to_enabled() {
481 assert!(AppConfig::default().brain_harvest.enabled);
482 }
483
484 #[test]
485 fn brain_harvest_missing_table_defaults_to_enabled() {
486 let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
487 assert!(cfg.brain_harvest.enabled);
488 }
489
490 #[test]
491 fn brain_harvest_can_be_disabled_via_config() {
492 let toml_src = "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n";
493 let cfg: AppConfig = toml::from_str(toml_src).unwrap();
494 assert!(!cfg.brain_harvest.enabled);
495 }
496
497 #[test]
498 fn inbox_messaging_defaults_to_disabled() {
499 assert!(!AppConfig::default().inbox_messaging.enabled);
500 }
501
502 #[test]
503 fn inbox_messaging_missing_table_defaults_to_disabled() {
504 let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
505 assert!(!cfg.inbox_messaging.enabled);
506 }
507
508 #[test]
509 fn inbox_messaging_can_be_enabled_via_config() {
510 let toml_src = "port = 8080\nfont_size = 13.0\n\n[inbox_messaging]\nenabled = true\n";
511 let cfg: AppConfig = toml::from_str(toml_src).unwrap();
512 assert!(cfg.inbox_messaging.enabled);
513 }
514
515 #[test]
516 fn catalogue_options_appends_configured_catalogues_and_skips_duplicate_default() {
517 let mut cfg = AppConfig::default();
518 cfg.brain.catalogues = vec![
519 CatalogueRef { name: "docs".to_string(), path: PathBuf::from("/tmp/docs-brain") },
520 CatalogueRef { name: "default".to_string(), path: PathBuf::from("/tmp/should-be-skipped") },
521 ];
522 let options = cfg.catalogue_options();
523 assert_eq!(options.len(), 2);
524 assert_eq!(options[0].name, "default");
525 assert_eq!(options[1].name, "docs");
526 assert_eq!(options[1].path, PathBuf::from("/tmp/docs-brain"));
527 }
528}