1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Deserialize, Serialize, Clone)]
6pub struct Settings {
7 #[serde(default, skip_serializing_if = "Vec::is_empty")]
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
36impl serde::Serialize for ProviderConfig {
37 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
38 where
39 S: serde::Serializer,
40 {
41 match self {
42 ProviderConfig::Explicit(s) => serializer.serialize_str(s),
43 ProviderConfig::Inferred | ProviderConfig::None => serializer.serialize_none(),
44 }
45 }
46}
47
48fn deserialize_provider_config<'de, D>(deserializer: D) -> Result<Option<ProviderConfig>, D::Error>
49where
50 D: serde::Deserializer<'de>,
51{
52 let config = ProviderConfig::deserialize(deserializer)?;
53 Ok(Some(config))
54}
55
56#[expect(
57 clippy::ref_option,
58 reason = "&Option<T> is required by serde skip_serializing_if"
59)]
60fn is_inferred_or_absent_provider(value: &Option<ProviderConfig>) -> bool {
61 matches!(value, Option::None | Some(ProviderConfig::Inferred))
62}
63
64#[expect(
65 clippy::ref_option,
66 reason = "&Option<T> is required by serde serialize_with"
67)]
68fn serialize_provider_config<S>(
69 value: &Option<ProviderConfig>,
70 serializer: S,
71) -> Result<S::Ok, S::Error>
72where
73 S: serde::Serializer,
74{
75 match value {
76 Some(ProviderConfig::Explicit(s)) => serializer.serialize_str(s),
77 Option::None | Some(ProviderConfig::Inferred | ProviderConfig::None) => {
78 serializer.serialize_none()
79 }
80 }
81}
82
83#[derive(Debug, Deserialize, Serialize, Clone)]
84pub struct AgentConfig {
85 pub command: String,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub args: Vec<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub models: Option<HashMap<String, String>>,
90 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91 pub arg_maps: HashMap<String, Vec<String>>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub env: Option<HashMap<String, String>>,
94 #[serde(
95 default,
96 deserialize_with = "deserialize_provider_config",
97 serialize_with = "serialize_provider_config",
98 skip_serializing_if = "is_inferred_or_absent_provider"
99 )]
100 pub provider: Option<ProviderConfig>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub openrouter_management_key: Option<String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub pre_command: Vec<String>,
105}
106
107#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
108pub struct PriorityRule {
109 pub command: String,
110 #[serde(
111 default,
112 deserialize_with = "deserialize_provider_config",
113 serialize_with = "serialize_provider_config",
114 skip_serializing_if = "is_inferred_or_absent_provider"
115 )]
116 pub provider: Option<ProviderConfig>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub model: Option<String>,
119 pub priority: i32,
120}
121
122fn command_to_provider(command: &str) -> Option<&str> {
123 match command {
124 "claude" => Some("claude"),
125 "codex" => Some("codex"),
126 "copilot" => Some("copilot"),
127 _ => None,
128 }
129}
130
131fn resolve_provider<'a>(command: &'a str, provider: Option<&'a ProviderConfig>) -> Option<&'a str> {
132 match provider {
133 Some(ProviderConfig::Explicit(name)) => Some(name.as_str()),
134 Some(ProviderConfig::None) => Option::None,
135 Some(ProviderConfig::Inferred) | Option::None => command_to_provider(command),
136 }
137}
138
139fn provider_to_domain(provider: &str) -> Option<&str> {
140 match provider {
141 "claude" => Some("claude.ai"),
142 "codex" => Some("chatgpt.com"),
143 "copilot" => Some("github.com"),
144 _ => None,
145 }
146}
147
148impl AgentConfig {
149 #[must_use]
150 pub fn resolve_provider(&self) -> Option<&str> {
151 resolve_provider(&self.command, self.provider.as_ref())
152 }
153
154 #[must_use]
155 pub fn resolve_domain(&self) -> Option<&str> {
156 self.resolve_provider().and_then(provider_to_domain)
157 }
158}
159
160impl PriorityRule {
161 #[must_use]
162 pub fn resolve_provider(&self) -> Option<&str> {
163 resolve_provider(&self.command, self.provider.as_ref())
164 }
165
166 #[must_use]
167 pub fn matches(&self, command: &str, provider: Option<&str>, model: Option<&str>) -> bool {
168 self.command == command
169 && self.resolve_provider() == provider
170 && self.model.as_deref() == model
171 }
172}
173
174impl Default for Settings {
175 fn default() -> Self {
176 Self {
177 priority: vec![],
178 agents: vec![AgentConfig {
179 command: "claude".to_string(),
180 args: vec![],
181 models: None,
182 arg_maps: HashMap::new(),
183 env: None,
184 provider: None,
185 openrouter_management_key: None,
186 pre_command: vec![],
187 }],
188 }
189 }
190}
191
192fn strip_trailing_commas(s: &str) -> String {
193 let chars: Vec<char> = s.chars().collect();
194 let mut result = String::with_capacity(s.len());
195 let mut i = 0;
196 let mut in_string = false;
197
198 while i < chars.len() {
199 let c = chars[i];
200
201 if in_string {
202 result.push(c);
203 if c == '\\' && i + 1 < chars.len() {
204 i += 1;
205 result.push(chars[i]);
206 } else if c == '"' {
207 in_string = false;
208 }
209 } else if c == '"' {
210 in_string = true;
211 result.push(c);
212 } else if c == ',' {
213 let mut j = i + 1;
214 while j < chars.len() && chars[j].is_whitespace() {
215 j += 1;
216 }
217 if j < chars.len() && (chars[j] == ']' || chars[j] == '}') {
218 } else {
220 result.push(c);
221 }
222 } else {
223 result.push(c);
224 }
225
226 i += 1;
227 }
228
229 result
230}
231
232impl Settings {
233 #[must_use]
234 pub fn priority_for(&self, agent: &AgentConfig, model: Option<&str>) -> i32 {
235 self.priority_for_components(&agent.command, agent.resolve_provider(), model)
236 }
237
238 #[must_use]
239 pub fn priority_for_components(
240 &self,
241 command: &str,
242 provider: Option<&str>,
243 model: Option<&str>,
244 ) -> i32 {
245 self.priority
246 .iter()
247 .find(|rule| rule.matches(command, provider, model))
248 .map_or(0, |rule| rule.priority)
249 }
250
251 pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
255 let path = match path {
256 Some(p) => p.to_path_buf(),
257 None => Self::settings_path()?,
258 };
259 let content = match std::fs::read_to_string(&path) {
260 Ok(c) => c,
261 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
262 return Ok(Settings::default());
263 }
264 Err(e) => return Err(e.into()),
265 };
266 let mut stripped = json_comments::StripComments::new(content.as_bytes());
267 let mut json_str = String::new();
268 std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
269 let clean = strip_trailing_commas(&json_str);
270 let settings: Settings = serde_json::from_str(&clean)?;
271 Ok(settings)
272 }
273
274 pub fn save(&self, path: Option<&Path>) -> Result<(), Box<dyn std::error::Error>> {
278 let path = match path {
279 Some(p) => p.to_path_buf(),
280 None => Self::settings_path()?,
281 };
282 let json = serde_json::to_string_pretty(self)?;
283 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
284 std::fs::create_dir_all(parent)?;
285 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
286 std::io::Write::write_all(&mut tmp, json.as_bytes())?;
287 std::io::Write::flush(&mut tmp)?;
288 tmp.persist(&path).map_err(|e| e.error)?;
289 Ok(())
290 }
291
292 pub fn upsert_priority(
295 &mut self,
296 command: &str,
297 provider: Option<ProviderConfig>,
298 model: Option<String>,
299 priority: i32,
300 ) {
301 for rule in &mut self.priority {
302 if rule.command == command && rule.provider == provider && rule.model == model {
303 rule.priority = priority;
304 return;
305 }
306 }
307 self.priority.push(PriorityRule {
308 command: command.to_string(),
309 provider,
310 model,
311 priority,
312 });
313 }
314
315 pub fn remove_priority(
317 &mut self,
318 command: &str,
319 provider: Option<&ProviderConfig>,
320 model: Option<&str>,
321 ) {
322 self.priority.retain(|rule| {
323 !(rule.command == command
324 && rule.provider.as_ref() == provider
325 && rule.model.as_deref() == model)
326 });
327 }
328
329 fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
330 let home = dirs::home_dir().ok_or("HOME directory not found")?;
331 let dir = home.join(".config").join("seher");
332 let jsonc_path = dir.join("settings.jsonc");
333 if jsonc_path.exists() {
334 return Ok(jsonc_path);
335 }
336 Ok(dir.join("settings.json"))
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 type TestResult = Result<(), Box<dyn std::error::Error>>;
345
346 fn sample_settings_path() -> PathBuf {
347 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
348 .join("examples")
349 .join("settings.json")
350 }
351
352 fn load_sample() -> Result<Settings, Box<dyn std::error::Error>> {
353 let content = std::fs::read_to_string(sample_settings_path())?;
354 let settings: Settings = serde_json::from_str(&content)?;
355 Ok(settings)
356 }
357
358 #[test]
359 fn test_parse_sample_settings() -> TestResult {
360 let settings = load_sample()?;
361
362 assert_eq!(settings.priority.len(), 4);
363 assert_eq!(settings.agents.len(), 4);
364 Ok(())
365 }
366
367 #[test]
368 fn test_sample_settings_priority_rules() -> TestResult {
369 let settings = load_sample()?;
370
371 assert_eq!(
372 settings.priority[0],
373 PriorityRule {
374 command: "opencode".to_string(),
375 provider: Some(ProviderConfig::Explicit("copilot".to_string())),
376 model: Some("high".to_string()),
377 priority: 100,
378 }
379 );
380 assert_eq!(
381 settings.priority[2],
382 PriorityRule {
383 command: "claude".to_string(),
384 provider: Some(ProviderConfig::None),
385 model: Some("medium".to_string()),
386 priority: 25,
387 }
388 );
389 Ok(())
390 }
391
392 #[test]
393 fn test_sample_settings_claude_agent() -> TestResult {
394 let settings = load_sample()?;
395
396 let claude = &settings.agents[0];
397 assert_eq!(claude.command, "claude");
398 assert_eq!(claude.args, ["--model", "{model}"]);
399
400 let models = claude.models.as_ref();
401 assert!(models.is_some());
402 let models = models.ok_or("models should be present")?;
403 assert_eq!(models.get("high").map(String::as_str), Some("opus"));
404 assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
405 assert_eq!(
406 claude.arg_maps.get("--danger").cloned(),
407 Some(vec![
408 "--permission-mode".to_string(),
409 "bypassPermissions".to_string(),
410 ])
411 );
412
413 assert!(claude.provider.is_none());
415 assert_eq!(claude.resolve_domain(), Some("claude.ai"));
416 Ok(())
417 }
418
419 #[test]
420 fn test_sample_settings_copilot_agent() -> TestResult {
421 let settings = load_sample()?;
422
423 let opencode = &settings.agents[1];
424 assert_eq!(opencode.command, "opencode");
425 assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
426
427 let models = opencode.models.as_ref().ok_or("models should be present")?;
428 assert_eq!(
429 models.get("high").map(String::as_str),
430 Some("github-copilot/gpt-5.4")
431 );
432 assert_eq!(
433 models.get("low").map(String::as_str),
434 Some("github-copilot/claude-haiku-4.5")
435 );
436
437 assert_eq!(
439 opencode.provider,
440 Some(ProviderConfig::Explicit("copilot".to_string()))
441 );
442 assert_eq!(opencode.resolve_domain(), Some("github.com"));
443 Ok(())
444 }
445
446 #[test]
447 fn test_sample_settings_fallback_agent() -> TestResult {
448 let settings = load_sample()?;
449
450 let fallback = &settings.agents[3];
451 assert_eq!(fallback.command, "claude");
452
453 assert_eq!(fallback.provider, Some(ProviderConfig::None));
455 assert_eq!(fallback.resolve_domain(), None);
456 Ok(())
457 }
458
459 #[test]
460 fn test_sample_settings_codex_agent() -> TestResult {
461 let settings = load_sample()?;
462
463 let codex = &settings.agents[2];
464 assert_eq!(codex.command, "codex");
465 assert!(codex.args.is_empty());
466 assert!(codex.models.is_none());
467 assert!(codex.provider.is_none());
468 assert_eq!(codex.resolve_domain(), Some("chatgpt.com"));
469 assert_eq!(codex.pre_command, ["git", "pull", "--rebase"]);
470 Ok(())
471 }
472
473 #[test]
474 fn test_provider_field_absent() -> TestResult {
475 let json = r#"{"agents": [{"command": "claude"}]}"#;
476 let settings: Settings = serde_json::from_str(json)?;
477
478 assert!(settings.agents[0].provider.is_none());
479 assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
480 assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
481 Ok(())
482 }
483
484 #[test]
485 fn test_provider_field_null() -> TestResult {
486 let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
487 let settings: Settings = serde_json::from_str(json)?;
488
489 assert_eq!(settings.agents[0].provider, Some(ProviderConfig::None));
490 assert_eq!(settings.agents[0].resolve_provider(), None);
491 assert_eq!(settings.agents[0].resolve_domain(), None);
492 Ok(())
493 }
494
495 #[test]
496 fn test_provider_field_string() -> TestResult {
497 let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
498 let settings: Settings = serde_json::from_str(json)?;
499
500 assert_eq!(
501 settings.agents[0].provider,
502 Some(ProviderConfig::Explicit("copilot".to_string()))
503 );
504 assert_eq!(settings.agents[0].resolve_provider(), Some("copilot"));
505 assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
506 Ok(())
507 }
508
509 #[test]
510 fn test_priority_defaults_to_empty() {
511 let settings = Settings::default();
512
513 assert!(settings.priority.is_empty());
514 }
515
516 #[test]
517 fn test_priority_defaults_to_zero_when_no_rule_matches() -> TestResult {
518 let json = r#"{"priority": [{"command": "claude", "model": "high", "priority": 10}], "agents": [{"command": "codex"}]}"#;
519 let settings: Settings = serde_json::from_str(json)?;
520
521 assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 0);
522 assert_eq!(
523 settings.priority_for_components("claude", Some("claude"), None),
524 0
525 );
526 Ok(())
527 }
528
529 #[test]
530 fn test_priority_matches_inferred_provider_and_model() -> TestResult {
531 let json = r#"{
532 "priority": [
533 {"command": "claude", "model": "high", "priority": 42}
534 ],
535 "agents": [{"command": "claude"}]
536 }"#;
537 let settings: Settings = serde_json::from_str(json)?;
538
539 assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 42);
540 Ok(())
541 }
542
543 #[test]
544 fn test_priority_matches_null_provider_for_fallback_agent() -> TestResult {
545 let json = r#"{
546 "priority": [
547 {"command": "claude", "provider": null, "model": "medium", "priority": 25}
548 ],
549 "agents": [{"command": "claude", "provider": null}]
550 }"#;
551 let settings: Settings = serde_json::from_str(json)?;
552
553 assert_eq!(
554 settings.priority_for(&settings.agents[0], Some("medium")),
555 25
556 );
557 Ok(())
558 }
559
560 #[test]
561 fn test_priority_supports_full_i32_range() -> TestResult {
562 let json = r#"{
563 "priority": [
564 {"command": "claude", "model": "high", "priority": 2147483647},
565 {"command": "claude", "provider": null, "priority": -2147483648}
566 ],
567 "agents": [
568 {"command": "claude"},
569 {"command": "claude", "provider": null}
570 ]
571 }"#;
572 let settings: Settings = serde_json::from_str(json)?;
573
574 assert_eq!(
575 settings.priority_for(&settings.agents[0], Some("high")),
576 i32::MAX
577 );
578 assert_eq!(settings.priority_for(&settings.agents[1], None), i32::MIN);
579 Ok(())
580 }
581
582 #[test]
583 fn test_command_codex_resolves_chatgpt_domain() -> TestResult {
584 let json = r#"{"agents": [{"command": "codex"}]}"#;
585 let settings: Settings = serde_json::from_str(json)?;
586
587 assert!(settings.agents[0].provider.is_none());
588 assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
589 Ok(())
590 }
591
592 #[test]
593 fn test_provider_field_codex_string() -> TestResult {
594 let json = r#"{"agents": [{"command": "opencode", "provider": "codex"}]}"#;
595 let settings: Settings = serde_json::from_str(json)?;
596
597 assert_eq!(
598 settings.agents[0].provider,
599 Some(ProviderConfig::Explicit("codex".to_string()))
600 );
601 assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
602 Ok(())
603 }
604
605 #[test]
606 fn test_provider_unknown_string() -> TestResult {
607 let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
608 let settings: Settings = serde_json::from_str(json)?;
609
610 assert_eq!(
611 settings.agents[0].provider,
612 Some(ProviderConfig::Explicit("unknown".to_string()))
613 );
614 assert_eq!(settings.agents[0].resolve_domain(), None);
615 Ok(())
616 }
617
618 #[test]
619 fn test_parse_minimal_settings_without_models() -> TestResult {
620 let json = r#"{"agents": [{"command": "claude"}]}"#;
621 let settings: Settings = serde_json::from_str(json)?;
622
623 assert_eq!(settings.agents.len(), 1);
624 assert_eq!(settings.agents[0].command, "claude");
625 assert!(settings.agents[0].args.is_empty());
626 assert!(settings.agents[0].models.is_none());
627 assert!(settings.agents[0].arg_maps.is_empty());
628 Ok(())
629 }
630
631 #[test]
632 fn test_parse_settings_with_env() -> TestResult {
633 let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
634 let settings: Settings = serde_json::from_str(json)?;
635
636 let env = settings.agents[0]
637 .env
638 .as_ref()
639 .ok_or("env should be present")?;
640 assert_eq!(
641 env.get("ANTHROPIC_API_KEY").map(String::as_str),
642 Some("sk-test")
643 );
644 assert_eq!(env.get("CLAUDE_CODE_MAX_HOURS").map(String::as_str), None);
645 assert_eq!(
646 env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
647 Some("100")
648 );
649 Ok(())
650 }
651
652 #[test]
653 fn test_parse_settings_with_args_no_models() -> TestResult {
654 let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
655 let settings: Settings = serde_json::from_str(json)?;
656
657 assert_eq!(
658 settings.agents[0].args,
659 ["--permission-mode", "bypassPermissions"]
660 );
661 assert!(settings.agents[0].models.is_none());
662 assert!(settings.agents[0].arg_maps.is_empty());
663 Ok(())
664 }
665
666 #[test]
667 fn test_parse_jsonc_with_comments() -> TestResult {
668 let jsonc = r#"{
669 // This is a comment
670 "agents": [
671 {
672 "command": "claude", /* inline comment */
673 "args": ["--model", "{model}"]
674 }
675 ]
676 }"#;
677 let stripped = json_comments::StripComments::new(jsonc.as_bytes());
678 let settings: Settings = serde_json::from_reader(stripped)?;
679 assert_eq!(settings.agents.len(), 1);
680 assert_eq!(settings.agents[0].command, "claude");
681 Ok(())
682 }
683
684 #[test]
685 fn test_parse_jsonc_with_trailing_commas() -> TestResult {
686 let jsonc = r#"{
687 // trailing commas
688 "agents": [
689 {
690 "command": "claude",
691 "args": ["--model", "{model}"],
692 },
693 ]
694 }"#;
695 let mut stripped = json_comments::StripComments::new(jsonc.as_bytes());
696 let mut json_str = String::new();
697 std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
698 let clean = strip_trailing_commas(&json_str);
699 let settings: Settings = serde_json::from_str(&clean)?;
700 assert_eq!(settings.agents.len(), 1);
701 assert_eq!(settings.agents[0].command, "claude");
702 Ok(())
703 }
704
705 #[test]
706 fn test_parse_settings_with_arg_maps() -> TestResult {
707 let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
708 let settings: Settings = serde_json::from_str(json)?;
709
710 assert_eq!(
711 settings.agents[0].arg_maps.get("--danger").cloned(),
712 Some(vec![
713 "--permission-mode".to_string(),
714 "bypassPermissions".to_string(),
715 ])
716 );
717 Ok(())
718 }
719
720 #[test]
721 fn test_parse_settings_with_openrouter_management_key() -> TestResult {
722 let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
724
725 let settings: Settings = serde_json::from_str(json)?;
727
728 assert_eq!(
730 settings.agents[0].openrouter_management_key.as_deref(),
731 Some("sk-or-v1-abc123")
732 );
733 Ok(())
734 }
735
736 #[test]
737 fn test_openrouter_management_key_defaults_to_none_when_absent() -> TestResult {
738 let json = r#"{"agents": [{"command": "claude"}]}"#;
740
741 let settings: Settings = serde_json::from_str(json)?;
743
744 assert!(settings.agents[0].openrouter_management_key.is_none());
746 Ok(())
747 }
748
749 #[test]
750 fn test_openrouter_provider_resolves_provider_but_not_domain() -> TestResult {
751 let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
753
754 let settings: Settings = serde_json::from_str(json)?;
756
757 assert_eq!(settings.agents[0].resolve_provider(), Some("openrouter"));
760 assert_eq!(settings.agents[0].resolve_domain(), None);
761 Ok(())
762 }
763
764 #[test]
765 fn test_parse_settings_with_pre_command() -> TestResult {
766 let json =
767 r#"{"agents": [{"command": "claude", "pre_command": ["git", "pull", "--rebase"]}]}"#;
768 let settings: Settings = serde_json::from_str(json)?;
769
770 assert_eq!(settings.agents[0].pre_command, ["git", "pull", "--rebase"]);
771 Ok(())
772 }
773
774 #[test]
775 fn test_pre_command_defaults_to_empty_when_absent() -> TestResult {
776 let json = r#"{"agents": [{"command": "claude"}]}"#;
777 let settings: Settings = serde_json::from_str(json)?;
778
779 assert!(settings.agents[0].pre_command.is_empty());
780 Ok(())
781 }
782
783 #[test]
784 fn test_openrouter_management_key_is_ignored_for_other_providers() -> TestResult {
785 let json = r#"{"agents": [{"command": "claude", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
787
788 let settings: Settings = serde_json::from_str(json)?;
790
791 assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
793 assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
794 Ok(())
795 }
796
797 #[test]
800 fn test_serialize_roundtrip_sample_settings() -> TestResult {
801 let settings = load_sample()?;
802 let json = serde_json::to_string_pretty(&settings)?;
803 let reparsed: Settings = serde_json::from_str(&json)?;
804
805 assert_eq!(reparsed.agents.len(), settings.agents.len());
806 assert_eq!(reparsed.priority.len(), settings.priority.len());
807 assert_eq!(reparsed.agents[0].command, settings.agents[0].command);
808 Ok(())
809 }
810
811 #[test]
812 fn test_serialize_skips_empty_args() -> TestResult {
813 let json = r#"{"agents": [{"command": "claude"}]}"#;
814 let settings: Settings = serde_json::from_str(json)?;
815 let out = serde_json::to_string(&settings)?;
816 let val: serde_json::Value = serde_json::from_str(&out)?;
817
818 assert!(val["agents"][0]["args"].is_null());
819 Ok(())
820 }
821
822 #[test]
823 fn test_serialize_null_provider_roundtrip() -> TestResult {
824 let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
825 let settings: Settings = serde_json::from_str(json)?;
826 let out = serde_json::to_string(&settings)?;
827 let val: serde_json::Value = serde_json::from_str(&out)?;
828
829 assert!(val["agents"][0]["provider"].is_null());
830 Ok(())
831 }
832
833 #[test]
834 fn test_serialize_inferred_provider_skipped() -> TestResult {
835 let json = r#"{"agents": [{"command": "claude"}]}"#;
836 let settings: Settings = serde_json::from_str(json)?;
837 let out = serde_json::to_string(&settings)?;
838 let val: serde_json::Value = serde_json::from_str(&out)?;
839
840 assert!(val["agents"][0]["provider"].is_null());
842 Ok(())
843 }
844
845 #[test]
846 fn test_upsert_priority_creates_new_rule() {
847 let mut settings = Settings::default();
848 settings.upsert_priority("claude", None, Some("high".to_string()), 42);
849
850 assert_eq!(settings.priority.len(), 1);
851 assert_eq!(settings.priority[0].priority, 42);
852 assert_eq!(settings.priority[0].model.as_deref(), Some("high"));
853 }
854
855 #[test]
856 fn test_upsert_priority_updates_existing_rule() {
857 let mut settings = Settings::default();
858 settings.upsert_priority("claude", None, Some("high".to_string()), 10);
859 settings.upsert_priority("claude", None, Some("high".to_string()), 99);
860
861 assert_eq!(settings.priority.len(), 1);
862 assert_eq!(settings.priority[0].priority, 99);
863 }
864
865 #[test]
866 fn test_remove_priority_removes_matching_rule() {
867 let mut settings = Settings::default();
868 settings.upsert_priority("claude", None, Some("high".to_string()), 10);
869 settings.upsert_priority("claude", None, Some("low".to_string()), 5);
870 settings.remove_priority("claude", None, Some("high"));
871
872 assert_eq!(settings.priority.len(), 1);
873 assert_eq!(settings.priority[0].model.as_deref(), Some("low"));
874 }
875
876 #[test]
877 fn test_save_and_reload() -> TestResult {
878 let settings = load_sample()?;
879 let tmp = tempfile::NamedTempFile::new()?;
880 settings.save(Some(tmp.path()))?;
881
882 let content = std::fs::read_to_string(tmp.path())?;
883 let reloaded: Settings = serde_json::from_str(&content)?;
884
885 assert_eq!(reloaded.agents.len(), settings.agents.len());
886 assert_eq!(reloaded.priority.len(), settings.priority.len());
887 Ok(())
888 }
889}