1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Deserialize, Clone)]
6pub struct Settings {
7 #[serde(default)]
8 pub priority: Vec<PriorityRule>,
9 pub agents: Vec<AgentConfig>,
10}
11
12fn deserialize_provider<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
13where
14 D: serde::Deserializer<'de>,
15{
16 let opt: Option<String> = serde::Deserialize::deserialize(deserializer)?;
17 Ok(Some(opt))
18}
19
20#[derive(Debug, Deserialize, Clone)]
21pub struct AgentConfig {
22 pub command: String,
23 #[serde(default)]
24 pub args: Vec<String>,
25 #[serde(default)]
26 pub models: Option<HashMap<String, String>>,
27 #[serde(default)]
28 pub arg_maps: HashMap<String, Vec<String>>,
29 #[serde(default)]
30 pub env: Option<HashMap<String, String>>,
31 #[serde(default, deserialize_with = "deserialize_provider")]
32 pub provider: Option<Option<String>>,
33}
34
35#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
36pub struct PriorityRule {
37 pub command: String,
38 #[serde(default, deserialize_with = "deserialize_provider")]
39 pub provider: Option<Option<String>>,
40 #[serde(default)]
41 pub model: Option<String>,
42 pub priority: i32,
43}
44
45fn command_to_provider(command: &str) -> Option<&str> {
46 match command {
47 "claude" => Some("claude"),
48 "codex" => Some("codex"),
49 "copilot" => Some("copilot"),
50 _ => None,
51 }
52}
53
54fn resolve_provider<'a>(command: &'a str, provider: &'a Option<Option<String>>) -> Option<&'a str> {
55 match provider {
56 Some(Some(provider)) => Some(provider.as_str()),
57 Some(None) => None,
58 None => command_to_provider(command),
59 }
60}
61
62fn provider_to_domain(provider: &str) -> Option<&str> {
63 match provider {
64 "claude" => Some("claude.ai"),
65 "codex" => Some("chatgpt.com"),
66 "copilot" => Some("github.com"),
67 _ => None,
68 }
69}
70
71impl AgentConfig {
72 pub fn resolve_provider(&self) -> Option<&str> {
73 resolve_provider(&self.command, &self.provider)
74 }
75
76 pub fn resolve_domain(&self) -> Option<&str> {
77 self.resolve_provider().and_then(provider_to_domain)
78 }
79}
80
81impl PriorityRule {
82 pub fn resolve_provider(&self) -> Option<&str> {
83 resolve_provider(&self.command, &self.provider)
84 }
85
86 pub fn matches(&self, command: &str, provider: Option<&str>, model: Option<&str>) -> bool {
87 self.command == command
88 && self.resolve_provider() == provider
89 && self.model.as_deref() == model
90 }
91}
92
93impl Default for Settings {
94 fn default() -> Self {
95 Self {
96 priority: vec![],
97 agents: vec![AgentConfig {
98 command: "claude".to_string(),
99 args: vec![],
100 models: None,
101 arg_maps: HashMap::new(),
102 env: None,
103 provider: None,
104 }],
105 }
106 }
107}
108
109fn strip_trailing_commas(s: &str) -> String {
110 let chars: Vec<char> = s.chars().collect();
111 let mut result = String::with_capacity(s.len());
112 let mut i = 0;
113 let mut in_string = false;
114
115 while i < chars.len() {
116 let c = chars[i];
117
118 if in_string {
119 result.push(c);
120 if c == '\\' && i + 1 < chars.len() {
121 i += 1;
122 result.push(chars[i]);
123 } else if c == '"' {
124 in_string = false;
125 }
126 } else if c == '"' {
127 in_string = true;
128 result.push(c);
129 } else if c == ',' {
130 let mut j = i + 1;
131 while j < chars.len() && chars[j].is_whitespace() {
132 j += 1;
133 }
134 if j < chars.len() && (chars[j] == ']' || chars[j] == '}') {
135 } else {
137 result.push(c);
138 }
139 } else {
140 result.push(c);
141 }
142
143 i += 1;
144 }
145
146 result
147}
148
149impl Settings {
150 pub fn priority_for(&self, agent: &AgentConfig, model: Option<&str>) -> i32 {
151 self.priority_for_components(&agent.command, agent.resolve_provider(), model)
152 }
153
154 pub fn priority_for_components(
155 &self,
156 command: &str,
157 provider: Option<&str>,
158 model: Option<&str>,
159 ) -> i32 {
160 self.priority
161 .iter()
162 .find(|rule| rule.matches(command, provider, model))
163 .map_or(0, |rule| rule.priority)
164 }
165
166 pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
167 let path = match path {
168 Some(p) => p.to_path_buf(),
169 None => Self::settings_path()?,
170 };
171 let content = match std::fs::read_to_string(&path) {
172 Ok(c) => c,
173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
174 return Ok(Settings::default());
175 }
176 Err(e) => return Err(e.into()),
177 };
178 let mut stripped = json_comments::StripComments::new(content.as_bytes());
179 let mut json_str = String::new();
180 std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
181 let clean = strip_trailing_commas(&json_str);
182 let settings: Settings = serde_json::from_str(&clean)?;
183 Ok(settings)
184 }
185
186 fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
187 let home = dirs::home_dir().ok_or("HOME directory not found")?;
188 let dir = home.join(".config").join("seher");
189 let jsonc_path = dir.join("settings.jsonc");
190 if jsonc_path.exists() {
191 return Ok(jsonc_path);
192 }
193 Ok(dir.join("settings.json"))
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn sample_settings_path() -> PathBuf {
202 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
203 .join("examples")
204 .join("settings.json")
205 }
206
207 #[test]
208 fn test_parse_sample_settings() {
209 let content = std::fs::read_to_string(sample_settings_path())
210 .expect("examples/settings.json not found");
211 let settings: Settings = serde_json::from_str(&content).expect("failed to parse settings");
212
213 assert_eq!(settings.priority.len(), 4);
214 assert_eq!(settings.agents.len(), 4);
215 }
216
217 #[test]
218 fn test_sample_settings_priority_rules() {
219 let content = std::fs::read_to_string(sample_settings_path()).unwrap();
220 let settings: Settings = serde_json::from_str(&content).unwrap();
221
222 assert_eq!(
223 settings.priority[0],
224 PriorityRule {
225 command: "opencode".to_string(),
226 provider: Some(Some("copilot".to_string())),
227 model: Some("high".to_string()),
228 priority: 100,
229 }
230 );
231 assert_eq!(
232 settings.priority[2],
233 PriorityRule {
234 command: "claude".to_string(),
235 provider: Some(None),
236 model: Some("medium".to_string()),
237 priority: 25,
238 }
239 );
240 }
241
242 #[test]
243 fn test_sample_settings_claude_agent() {
244 let content = std::fs::read_to_string(sample_settings_path()).unwrap();
245 let settings: Settings = serde_json::from_str(&content).unwrap();
246
247 let claude = &settings.agents[0];
248 assert_eq!(claude.command, "claude");
249 assert_eq!(claude.args, ["--model", "{model}"]);
250
251 let models = claude.models.as_ref().expect("models should be present");
252 assert_eq!(models.get("high").map(String::as_str), Some("opus"));
253 assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
254 assert_eq!(
255 claude.arg_maps.get("--danger").cloned(),
256 Some(vec![
257 "--permission-mode".to_string(),
258 "bypassPermissions".to_string(),
259 ])
260 );
261
262 assert!(claude.provider.is_none());
264 assert_eq!(claude.resolve_domain(), Some("claude.ai"));
265 }
266
267 #[test]
268 fn test_sample_settings_copilot_agent() {
269 let content = std::fs::read_to_string(sample_settings_path()).unwrap();
270 let settings: Settings = serde_json::from_str(&content).unwrap();
271
272 let opencode = &settings.agents[1];
273 assert_eq!(opencode.command, "opencode");
274 assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
275
276 let models = opencode.models.as_ref().expect("models should be present");
277 assert_eq!(
278 models.get("high").map(String::as_str),
279 Some("github-copilot/gpt-5.4")
280 );
281 assert_eq!(
282 models.get("low").map(String::as_str),
283 Some("github-copilot/claude-haiku-4.5")
284 );
285
286 assert_eq!(opencode.provider, Some(Some("copilot".to_string())));
288 assert_eq!(opencode.resolve_domain(), Some("github.com"));
289 }
290
291 #[test]
292 fn test_sample_settings_fallback_agent() {
293 let content = std::fs::read_to_string(sample_settings_path()).unwrap();
294 let settings: Settings = serde_json::from_str(&content).unwrap();
295
296 let fallback = &settings.agents[3];
297 assert_eq!(fallback.command, "claude");
298
299 assert_eq!(fallback.provider, Some(None));
301 assert_eq!(fallback.resolve_domain(), None);
302 }
303
304 #[test]
305 fn test_sample_settings_codex_agent() {
306 let content = std::fs::read_to_string(sample_settings_path()).unwrap();
307 let settings: Settings = serde_json::from_str(&content).unwrap();
308
309 let codex = &settings.agents[2];
310 assert_eq!(codex.command, "codex");
311 assert!(codex.args.is_empty());
312 assert!(codex.models.is_none());
313 assert!(codex.provider.is_none());
314 assert_eq!(codex.resolve_domain(), Some("chatgpt.com"));
315 }
316
317 #[test]
318 fn test_provider_field_absent() {
319 let json = r#"{"agents": [{"command": "claude"}]}"#;
320 let settings: Settings = serde_json::from_str(json).unwrap();
321
322 assert!(settings.agents[0].provider.is_none());
323 assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
324 assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
325 }
326
327 #[test]
328 fn test_provider_field_null() {
329 let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
330 let settings: Settings = serde_json::from_str(json).unwrap();
331
332 assert_eq!(settings.agents[0].provider, Some(None));
333 assert_eq!(settings.agents[0].resolve_provider(), None);
334 assert_eq!(settings.agents[0].resolve_domain(), None);
335 }
336
337 #[test]
338 fn test_provider_field_string() {
339 let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
340 let settings: Settings = serde_json::from_str(json).unwrap();
341
342 assert_eq!(
343 settings.agents[0].provider,
344 Some(Some("copilot".to_string()))
345 );
346 assert_eq!(settings.agents[0].resolve_provider(), Some("copilot"));
347 assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
348 }
349
350 #[test]
351 fn test_priority_defaults_to_empty() {
352 let settings = Settings::default();
353
354 assert!(settings.priority.is_empty());
355 }
356
357 #[test]
358 fn test_priority_defaults_to_zero_when_no_rule_matches() {
359 let json = r#"{"priority": [{"command": "claude", "model": "high", "priority": 10}], "agents": [{"command": "codex"}]}"#;
360 let settings: Settings = serde_json::from_str(json).unwrap();
361
362 assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 0);
363 assert_eq!(
364 settings.priority_for_components("claude", Some("claude"), None),
365 0
366 );
367 }
368
369 #[test]
370 fn test_priority_matches_inferred_provider_and_model() {
371 let json = r#"{
372 "priority": [
373 {"command": "claude", "model": "high", "priority": 42}
374 ],
375 "agents": [{"command": "claude"}]
376 }"#;
377 let settings: Settings = serde_json::from_str(json).unwrap();
378
379 assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 42);
380 }
381
382 #[test]
383 fn test_priority_matches_null_provider_for_fallback_agent() {
384 let json = r#"{
385 "priority": [
386 {"command": "claude", "provider": null, "model": "medium", "priority": 25}
387 ],
388 "agents": [{"command": "claude", "provider": null}]
389 }"#;
390 let settings: Settings = serde_json::from_str(json).unwrap();
391
392 assert_eq!(
393 settings.priority_for(&settings.agents[0], Some("medium")),
394 25
395 );
396 }
397
398 #[test]
399 fn test_priority_supports_full_i32_range() {
400 let json = r#"{
401 "priority": [
402 {"command": "claude", "model": "high", "priority": 2147483647},
403 {"command": "claude", "provider": null, "priority": -2147483648}
404 ],
405 "agents": [
406 {"command": "claude"},
407 {"command": "claude", "provider": null}
408 ]
409 }"#;
410 let settings: Settings = serde_json::from_str(json).unwrap();
411
412 assert_eq!(
413 settings.priority_for(&settings.agents[0], Some("high")),
414 i32::MAX
415 );
416 assert_eq!(settings.priority_for(&settings.agents[1], None), i32::MIN);
417 }
418
419 #[test]
420 fn test_command_codex_resolves_chatgpt_domain() {
421 let json = r#"{"agents": [{"command": "codex"}]}"#;
422 let settings: Settings = serde_json::from_str(json).unwrap();
423
424 assert!(settings.agents[0].provider.is_none());
425 assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
426 }
427
428 #[test]
429 fn test_provider_field_codex_string() {
430 let json = r#"{"agents": [{"command": "opencode", "provider": "codex"}]}"#;
431 let settings: Settings = serde_json::from_str(json).unwrap();
432
433 assert_eq!(settings.agents[0].provider, Some(Some("codex".to_string())));
434 assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
435 }
436
437 #[test]
438 fn test_provider_unknown_string() {
439 let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
440 let settings: Settings = serde_json::from_str(json).unwrap();
441
442 assert_eq!(
443 settings.agents[0].provider,
444 Some(Some("unknown".to_string()))
445 );
446 assert_eq!(settings.agents[0].resolve_domain(), None);
447 }
448
449 #[test]
450 fn test_parse_minimal_settings_without_models() {
451 let json = r#"{"agents": [{"command": "claude"}]}"#;
452 let settings: Settings =
453 serde_json::from_str(json).expect("failed to parse minimal settings");
454
455 assert_eq!(settings.agents.len(), 1);
456 assert_eq!(settings.agents[0].command, "claude");
457 assert!(settings.agents[0].args.is_empty());
458 assert!(settings.agents[0].models.is_none());
459 assert!(settings.agents[0].arg_maps.is_empty());
460 }
461
462 #[test]
463 fn test_parse_settings_with_env() {
464 let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
465 let settings: Settings = serde_json::from_str(json).unwrap();
466
467 let env = settings.agents[0]
468 .env
469 .as_ref()
470 .expect("env should be present");
471 assert_eq!(
472 env.get("ANTHROPIC_API_KEY").map(String::as_str),
473 Some("sk-test")
474 );
475 assert_eq!(
476 env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
477 Some("100")
478 );
479 }
480
481 #[test]
482 fn test_parse_settings_with_args_no_models() {
483 let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
484 let settings: Settings = serde_json::from_str(json).unwrap();
485
486 assert_eq!(
487 settings.agents[0].args,
488 ["--permission-mode", "bypassPermissions"]
489 );
490 assert!(settings.agents[0].models.is_none());
491 assert!(settings.agents[0].arg_maps.is_empty());
492 }
493
494 #[test]
495 fn test_parse_jsonc_with_comments() {
496 let jsonc = r#"{
497 // This is a comment
498 "agents": [
499 {
500 "command": "claude", /* inline comment */
501 "args": ["--model", "{model}"]
502 }
503 ]
504 }"#;
505 let stripped = json_comments::StripComments::new(jsonc.as_bytes());
506 let settings: Settings = serde_json::from_reader(stripped).unwrap();
507 assert_eq!(settings.agents.len(), 1);
508 assert_eq!(settings.agents[0].command, "claude");
509 }
510
511 #[test]
512 fn test_parse_jsonc_with_trailing_commas() {
513 let jsonc = r#"{
514 // trailing commas
515 "agents": [
516 {
517 "command": "claude",
518 "args": ["--model", "{model}"],
519 },
520 ]
521 }"#;
522 let mut stripped = json_comments::StripComments::new(jsonc.as_bytes());
523 let mut json_str = String::new();
524 std::io::Read::read_to_string(&mut stripped, &mut json_str).unwrap();
525 let clean = strip_trailing_commas(&json_str);
526 let settings: Settings = serde_json::from_str(&clean).unwrap();
527 assert_eq!(settings.agents.len(), 1);
528 assert_eq!(settings.agents[0].command, "claude");
529 }
530
531 #[test]
532 fn test_parse_settings_with_arg_maps() {
533 let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
534 let settings: Settings = serde_json::from_str(json).unwrap();
535
536 assert_eq!(
537 settings.agents[0].arg_maps.get("--danger").cloned(),
538 Some(vec![
539 "--permission-mode".to_string(),
540 "bypassPermissions".to_string(),
541 ])
542 );
543 }
544}