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)]
88pub struct AppConfig {
89 pub port: u16,
90 pub font_size: f32,
91 #[serde(default)]
92 pub theme: ThemeVariant,
93 #[serde(default)]
96 pub orchestrator_root: Option<PathBuf>,
97 #[serde(default)]
99 pub orchestrator: AgentConfig,
100 #[serde(default)]
102 pub worker: AgentConfig,
103 #[serde(default)]
106 pub github_token: Option<String>,
107 #[serde(default)]
109 pub brain: BrainConfig,
110 #[serde(default)]
114 pub theme_file: Option<String>,
115 #[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 pub fn registry(&self) -> HarnessRegistry {
144 HarnessRegistry::from_config(&self.harnesses)
145 }
146
147 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 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 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 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 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 #[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 static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
311
312 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 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}