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