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