1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::{fs, path::PathBuf};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ThemeVariant {
12 Light,
13 #[default]
14 Dark,
15 Ninox,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AgentConfig {
36 #[serde(default = "default_harness")]
38 pub harness: String,
39 pub model: Option<String>,
42}
43
44fn default_harness() -> String {
45 "claude-code".to_string()
46}
47
48impl Default for AgentConfig {
49 fn default() -> Self {
50 Self { harness: default_harness(), model: None }
51 }
52}
53
54impl AgentConfig {
55 pub fn interactive_cmd(&self) -> String {
57 let binary = harness_binary(&self.harness);
58 match &self.model {
59 Some(m) => format!("{binary} --model {m}"),
60 None => binary.to_string(),
61 }
62 }
63
64 pub fn worker_cmd(&self, prompt: &str) -> String {
66 let binary = harness_binary(&self.harness);
67 let quoted = shell_quote(prompt);
68 match self.harness.as_str() {
69 "claude-code" => {
70 let model_part = self.model.as_deref()
74 .map(|m| format!(" --model {}", shell_quote(m)))
75 .unwrap_or_default();
76 format!("{binary} --dangerously-skip-permissions{model_part} -- {quoted}")
77 }
78 "aider" => {
79 let model_part = self.model.as_deref()
80 .map(|m| format!(" --model {}", shell_quote(m)))
81 .unwrap_or_default();
82 format!("{binary}{model_part} --message {quoted}")
83 }
84 _ => {
85 let model_part = self.model.as_deref()
86 .map(|m| format!(" --model {}", shell_quote(m)))
87 .unwrap_or_default();
88 format!("{binary}{model_part} -p {quoted}")
89 }
90 }
91 }
92}
93
94fn harness_binary(harness: &str) -> &str {
95 match harness {
96 "claude-code" => "claude",
97 "codex" => "codex",
98 "aider" => "aider",
99 "opencode" => "opencode",
100 other => other,
101 }
102}
103
104
105fn shell_quote(s: &str) -> String {
106 format!("'{}'", s.replace('\'', "'\\''"))
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
114pub struct BrainConfig {
115 pub path: Option<PathBuf>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AppConfig {
124 pub port: u16,
125 pub font_size: f32,
126 #[serde(default)]
127 pub theme: ThemeVariant,
128 #[serde(default)]
131 pub orchestrator_root: Option<PathBuf>,
132 #[serde(default)]
134 pub orchestrator: AgentConfig,
135 #[serde(default)]
137 pub worker: AgentConfig,
138 #[serde(default)]
141 pub github_token: Option<String>,
142 #[serde(default)]
144 pub brain: BrainConfig,
145}
146
147impl Default for AppConfig {
148 fn default() -> Self {
149 Self {
150 port: 8080,
151 font_size: 13.0,
152 theme: ThemeVariant::Dark,
153 orchestrator_root: None,
154 orchestrator: AgentConfig::default(),
155 worker: AgentConfig::default(),
156 github_token: None,
157 brain: BrainConfig::default(),
158 }
159 }
160}
161
162impl AppConfig {
163 pub fn resolved_brain_path(&self) -> PathBuf {
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 resolved_orchestrator_root(&self) -> PathBuf {
174 self.orchestrator_root.clone().unwrap_or_else(|| {
175 dirs::config_dir()
176 .unwrap_or_else(|| PathBuf::from("."))
177 .join("ninox")
178 .join("orchestrator")
179 })
180 }
181
182 pub fn config_path() -> PathBuf {
183 dirs::config_dir()
184 .unwrap_or_else(|| PathBuf::from("."))
185 .join("ninox")
186 .join("config.toml")
187 }
188
189 pub fn ninox_bin_dir() -> PathBuf {
192 dirs::config_dir()
193 .unwrap_or_else(|| PathBuf::from("."))
194 .join("ninox")
195 .join("bin")
196 }
197
198 pub fn sessions_dir() -> PathBuf {
201 dirs::config_dir()
202 .unwrap_or_else(|| PathBuf::from("."))
203 .join("ninox")
204 .join("sessions")
205 }
206
207 fn path() -> PathBuf { Self::config_path() }
208
209 pub fn load() -> Result<Self> {
210 let p = Self::path();
211 if !p.exists() { return Ok(Self::default()); }
212 Ok(toml::from_str(&fs::read_to_string(p)?)?)
213 }
214
215 pub fn save(&self) -> Result<()> {
216 let p = Self::path();
217 fs::create_dir_all(p.parent().unwrap())?;
218 fs::write(p, toml::to_string(self)?)?;
219 Ok(())
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use tempfile::tempdir;
227
228 #[test]
229 fn round_trip() {
230 let dir = tempdir().unwrap();
231 let path = dir.path().join("config.toml");
232 let cfg = AppConfig { port: 9090, font_size: 14.0, theme: ThemeVariant::Light, ..AppConfig::default() };
233 fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
234 let loaded: AppConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
235 assert_eq!(loaded.port, 9090);
236 assert_eq!(loaded.theme, ThemeVariant::Light);
237 assert!(loaded.orchestrator_root.is_none());
238 }
239
240 #[test]
241 fn default_theme_is_dark() {
242 assert_eq!(AppConfig::default().theme, ThemeVariant::Dark);
243 }
244
245 #[test]
246 fn missing_theme_field_defaults_to_dark() {
247 let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
248 assert_eq!(cfg.theme, ThemeVariant::Dark);
249 }
250
251 #[test]
252 fn agent_config_round_trip() {
253 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";
254 let cfg: AppConfig = toml::from_str(toml).unwrap();
255 assert_eq!(cfg.orchestrator.harness, "claude-code");
256 assert_eq!(cfg.orchestrator.model.as_deref(), Some("claude-opus-4-5"));
257 assert_eq!(cfg.worker.harness, "codex");
258 assert!(cfg.worker.model.is_none());
259 }
260
261 #[test]
262 fn interactive_cmd_with_model() {
263 let cfg = AgentConfig { harness: "claude-code".into(), model: Some("claude-opus-4-5".into()) };
264 assert_eq!(cfg.interactive_cmd(), "claude --model claude-opus-4-5");
265 }
266
267 #[test]
268 fn worker_cmd_codex() {
269 let cfg = AgentConfig { harness: "codex".into(), model: Some("gpt-4o".into()) };
270 assert_eq!(cfg.worker_cmd("do the thing"), "codex --model 'gpt-4o' -p 'do the thing'");
271 }
272
273 #[test]
274 fn worker_cmd_claude_code() {
275 let cfg = AgentConfig { harness: "claude-code".into(), model: None };
276 assert_eq!(cfg.worker_cmd("Fix the bug"), "claude --dangerously-skip-permissions -- 'Fix the bug'");
277 }
278
279 #[test]
280 fn worker_cmd_claude_code_with_model() {
281 let cfg = AgentConfig { harness: "claude-code".into(), model: Some("claude-opus-4-5".into()) };
282 assert_eq!(cfg.worker_cmd("do task"), "claude --dangerously-skip-permissions --model 'claude-opus-4-5' -- 'do task'");
283 }
284
285 #[test]
286 fn resolved_orchestrator_root_default() {
287 let cfg = AppConfig::default();
288 assert!(cfg.resolved_orchestrator_root().ends_with("ninox/orchestrator"));
289 }
290}