use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::PathBuf};
use crate::harness::{HarnessRegistry, HarnessSpec};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemeVariant {
Light,
#[default]
Dark,
Ninox,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(default = "default_harness")]
pub harness: String,
pub model: Option<String>,
}
fn default_harness() -> String {
"claude-code".to_string()
}
impl Default for AgentConfig {
fn default() -> Self {
Self { harness: default_harness(), model: None }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BrainConfig {
pub path: Option<PathBuf>,
#[serde(default)]
pub catalogues: Vec<CatalogueRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CatalogueRef {
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub port: u16,
pub font_size: f32,
#[serde(default)]
pub theme: ThemeVariant,
#[serde(default)]
pub orchestrator_root: Option<PathBuf>,
#[serde(default)]
pub orchestrator: AgentConfig,
#[serde(default)]
pub worker: AgentConfig,
#[serde(default)]
pub github_token: Option<String>,
#[serde(default)]
pub brain: BrainConfig,
#[serde(default)]
pub theme_file: Option<String>,
#[serde(default)]
pub harnesses: BTreeMap<String, HarnessSpec>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
port: 8080,
font_size: 13.0,
theme: ThemeVariant::Dark,
orchestrator_root: None,
orchestrator: AgentConfig::default(),
worker: AgentConfig::default(),
github_token: None,
brain: BrainConfig::default(),
theme_file: None,
harnesses: BTreeMap::new(),
}
}
}
impl AppConfig {
pub fn registry(&self) -> HarnessRegistry {
HarnessRegistry::from_config(&self.harnesses)
}
pub fn resolved_brain_path(&self) -> PathBuf {
if let Ok(p) = std::env::var("NINOX_BRAIN") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
if let Some(ref p) = self.brain.path {
return p.clone();
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ninox")
.join("brain")
}
pub fn catalogue_options(&self) -> Vec<CatalogueRef> {
let mut options = vec![CatalogueRef {
name: "default".to_string(),
path: self.resolved_brain_path(),
}];
options.extend(
self.brain
.catalogues
.iter()
.filter(|c| c.name != "default")
.cloned(),
);
options
}
pub fn resolved_orchestrator_root(&self) -> PathBuf {
self.orchestrator_root.clone().unwrap_or_else(|| {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ninox")
.join("orchestrator")
})
}
pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("NINOX_CONFIG") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ninox")
.join("config.toml")
}
pub fn ninox_bin_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ninox")
.join("bin")
}
pub fn sessions_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ninox")
.join("sessions")
}
fn path() -> PathBuf { Self::config_path() }
pub fn load() -> Result<Self> {
let p = Self::path();
if !p.exists() { return Ok(Self::default()); }
Ok(toml::from_str(&fs::read_to_string(p)?)?)
}
pub fn save(&self) -> Result<()> {
let p = Self::path();
fs::create_dir_all(p.parent().unwrap())?;
fs::write(p, toml::to_string(self)?)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = AppConfig { port: 9090, font_size: 14.0, theme: ThemeVariant::Light, ..AppConfig::default() };
fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
let loaded: AppConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.port, 9090);
assert_eq!(loaded.theme, ThemeVariant::Light);
assert!(loaded.orchestrator_root.is_none());
}
#[test]
fn default_theme_is_dark() {
assert_eq!(AppConfig::default().theme, ThemeVariant::Dark);
}
#[test]
fn missing_theme_field_defaults_to_dark() {
let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
assert_eq!(cfg.theme, ThemeVariant::Dark);
}
#[test]
fn agent_config_round_trip() {
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";
let cfg: AppConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.orchestrator.harness, "claude-code");
assert_eq!(cfg.orchestrator.model.as_deref(), Some("claude-opus-4-5"));
assert_eq!(cfg.worker.harness, "codex");
assert!(cfg.worker.model.is_none());
}
#[test]
fn resolved_orchestrator_root_default() {
let cfg = AppConfig::default();
assert!(cfg.resolved_orchestrator_root().ends_with("ninox/orchestrator"));
}
static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env_override<T>(
key: &str,
value: impl AsRef<std::ffi::OsStr>,
f: impl FnOnce() -> T,
) -> T {
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var(key).ok();
std::env::set_var(key, value);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match prior {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
result.unwrap()
}
#[test]
fn config_path_honors_ninox_config_env() {
let dir = tempdir().unwrap();
let override_path = dir.path().join("config_path_honors_ninox_config_env.toml");
with_env_override("NINOX_CONFIG", &override_path, || {
assert_eq!(AppConfig::config_path(), override_path);
});
}
#[test]
fn resolved_brain_path_honors_ninox_brain_env() {
let dir = tempdir().unwrap();
let override_path = dir.path().join("brain-override");
with_env_override("NINOX_BRAIN", &override_path, || {
let cfg = AppConfig::default();
assert_eq!(cfg.resolved_brain_path(), override_path);
});
}
#[test]
fn catalogue_options_defaults_to_single_entry() {
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let cfg = AppConfig::default();
let options = cfg.catalogue_options();
assert_eq!(options.len(), 1);
assert_eq!(options[0].name, "default");
assert_eq!(options[0].path, cfg.resolved_brain_path());
}
#[test]
fn catalogue_options_appends_configured_catalogues_and_skips_duplicate_default() {
let mut cfg = AppConfig::default();
cfg.brain.catalogues = vec![
CatalogueRef { name: "docs".to_string(), path: PathBuf::from("/tmp/docs-brain") },
CatalogueRef { name: "default".to_string(), path: PathBuf::from("/tmp/should-be-skipped") },
];
let options = cfg.catalogue_options();
assert_eq!(options.len(), 2);
assert_eq!(options[0].name, "default");
assert_eq!(options[1].name, "docs");
assert_eq!(options[1].path, PathBuf::from("/tmp/docs-brain"));
}
}