1use jsonc_parser::cst::{CstInputValue, CstRootNode};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Deserialize, Serialize, Clone)]
7pub struct Settings {
8 #[serde(default, skip_serializing_if = "Vec::is_empty")]
9 pub priority: Vec<PriorityRule>,
10 pub agents: Vec<AgentConfig>,
11 #[serde(skip)]
12 original_text: Option<String>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ProviderConfig {
21 Inferred,
22 Explicit(String),
23 None,
24}
25
26impl<'de> serde::Deserialize<'de> for ProviderConfig {
27 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
28 where
29 D: serde::Deserializer<'de>,
30 {
31 let opt: Option<String> = serde::Deserialize::deserialize(deserializer)?;
32 Ok(match opt {
33 Some(s) => ProviderConfig::Explicit(s),
34 Option::None => ProviderConfig::None,
35 })
36 }
37}
38
39impl serde::Serialize for ProviderConfig {
40 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
41 where
42 S: serde::Serializer,
43 {
44 match self {
45 ProviderConfig::Explicit(s) => serializer.serialize_str(s),
46 ProviderConfig::Inferred | ProviderConfig::None => serializer.serialize_none(),
47 }
48 }
49}
50
51fn deserialize_provider_config<'de, D>(deserializer: D) -> Result<Option<ProviderConfig>, D::Error>
52where
53 D: serde::Deserializer<'de>,
54{
55 let config = ProviderConfig::deserialize(deserializer)?;
56 Ok(Some(config))
57}
58
59#[expect(
60 clippy::ref_option,
61 reason = "&Option<T> is required by serde skip_serializing_if"
62)]
63fn is_inferred_or_absent_provider(value: &Option<ProviderConfig>) -> bool {
64 matches!(value, Option::None | Some(ProviderConfig::Inferred))
65}
66
67#[expect(
68 clippy::ref_option,
69 reason = "&Option<T> is required by serde serialize_with"
70)]
71fn serialize_provider_config<S>(
72 value: &Option<ProviderConfig>,
73 serializer: S,
74) -> Result<S::Ok, S::Error>
75where
76 S: serde::Serializer,
77{
78 match value {
79 Some(ProviderConfig::Explicit(s)) => serializer.serialize_str(s),
80 Option::None | Some(ProviderConfig::Inferred | ProviderConfig::None) => {
81 serializer.serialize_none()
82 }
83 }
84}
85
86#[derive(Debug, Deserialize, Serialize, Clone)]
87pub struct AgentConfig {
88 pub command: String,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub args: Vec<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub models: Option<HashMap<String, String>>,
93 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94 pub arg_maps: HashMap<String, Vec<String>>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub env: Option<HashMap<String, String>>,
97 #[serde(
98 default,
99 deserialize_with = "deserialize_provider_config",
100 serialize_with = "serialize_provider_config",
101 skip_serializing_if = "is_inferred_or_absent_provider"
102 )]
103 pub provider: Option<ProviderConfig>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub openrouter_management_key: Option<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub pre_command: Vec<String>,
108}
109
110#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
111pub struct PriorityRule {
112 pub command: String,
113 #[serde(
114 default,
115 deserialize_with = "deserialize_provider_config",
116 serialize_with = "serialize_provider_config",
117 skip_serializing_if = "is_inferred_or_absent_provider"
118 )]
119 pub provider: Option<ProviderConfig>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub model: Option<String>,
122 pub priority: i32,
123}
124
125fn command_to_provider(command: &str) -> Option<&str> {
126 match command {
127 "claude" => Some("claude"),
128 "codex" => Some("codex"),
129 "copilot" => Some("copilot"),
130 _ => None,
131 }
132}
133
134fn resolve_provider<'a>(command: &'a str, provider: Option<&'a ProviderConfig>) -> Option<&'a str> {
135 match provider {
136 Some(ProviderConfig::Explicit(name)) => Some(name.as_str()),
137 Some(ProviderConfig::None) => Option::None,
138 Some(ProviderConfig::Inferred) | Option::None => command_to_provider(command),
139 }
140}
141
142fn provider_to_domain(provider: &str) -> Option<&str> {
143 match provider {
144 "claude" => Some("claude.ai"),
145 "codex" => Some("chatgpt.com"),
146 "copilot" => Some("github.com"),
147 _ => None,
148 }
149}
150
151impl AgentConfig {
152 #[must_use]
153 pub fn resolve_provider(&self) -> Option<&str> {
154 resolve_provider(&self.command, self.provider.as_ref())
155 }
156
157 #[must_use]
158 pub fn resolve_domain(&self) -> Option<&str> {
159 self.resolve_provider().and_then(provider_to_domain)
160 }
161}
162
163impl PriorityRule {
164 #[must_use]
165 pub fn resolve_provider(&self) -> Option<&str> {
166 resolve_provider(&self.command, self.provider.as_ref())
167 }
168
169 #[must_use]
170 pub fn matches(&self, command: &str, provider: Option<&str>, model: Option<&str>) -> bool {
171 self.command == command
172 && self.resolve_provider() == provider
173 && self.model.as_deref() == model
174 }
175}
176
177impl Default for Settings {
178 fn default() -> Self {
179 Self {
180 priority: vec![],
181 agents: vec![AgentConfig {
182 command: "claude".to_string(),
183 args: vec![],
184 models: None,
185 arg_maps: HashMap::new(),
186 env: None,
187 provider: None,
188 openrouter_management_key: None,
189 pre_command: vec![],
190 }],
191 original_text: None,
192 }
193 }
194}
195
196fn serde_value_to_cst_input(val: &serde_json::Value) -> CstInputValue {
197 match val {
198 serde_json::Value::Null => CstInputValue::Null,
199 serde_json::Value::Bool(b) => CstInputValue::Bool(*b),
200 serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()),
201 serde_json::Value::String(s) => CstInputValue::String(s.clone()),
202 serde_json::Value::Array(arr) => {
203 CstInputValue::Array(arr.iter().map(serde_value_to_cst_input).collect())
204 }
205 serde_json::Value::Object(obj) => CstInputValue::Object(
206 obj.iter()
207 .map(|(k, v)| (k.clone(), serde_value_to_cst_input(v)))
208 .collect(),
209 ),
210 }
211}
212
213fn strip_trailing_commas(s: &str) -> String {
214 let chars: Vec<char> = s.chars().collect();
215 let mut result = String::with_capacity(s.len());
216 let mut i = 0;
217 let mut in_string = false;
218
219 while i < chars.len() {
220 let c = chars[i];
221
222 if in_string {
223 result.push(c);
224 if c == '\\' && i + 1 < chars.len() {
225 i += 1;
226 result.push(chars[i]);
227 } else if c == '"' {
228 in_string = false;
229 }
230 } else if c == '"' {
231 in_string = true;
232 result.push(c);
233 } else if c == ',' {
234 let mut j = i + 1;
235 while j < chars.len() && chars[j].is_whitespace() {
236 j += 1;
237 }
238 if j < chars.len() && (chars[j] == ']' || chars[j] == '}') {
239 } else {
241 result.push(c);
242 }
243 } else {
244 result.push(c);
245 }
246
247 i += 1;
248 }
249
250 result
251}
252
253impl Settings {
254 #[must_use]
255 pub fn priority_for(&self, agent: &AgentConfig, model: Option<&str>) -> i32 {
256 self.priority_for_components(&agent.command, agent.resolve_provider(), model)
257 }
258
259 #[must_use]
260 pub fn priority_for_components(
261 &self,
262 command: &str,
263 provider: Option<&str>,
264 model: Option<&str>,
265 ) -> i32 {
266 self.priority
267 .iter()
268 .find(|rule| rule.matches(command, provider, model))
269 .map_or(0, |rule| rule.priority)
270 }
271
272 pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
276 let path = match path {
277 Some(p) => p.to_path_buf(),
278 None => Self::settings_path()?,
279 };
280 let content = match std::fs::read_to_string(&path) {
281 Ok(c) => c,
282 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
283 return Ok(Settings::default());
284 }
285 Err(e) => return Err(e.into()),
286 };
287 let mut stripped = json_comments::StripComments::new(content.as_bytes());
288 let mut json_str = String::new();
289 std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
290 let clean = strip_trailing_commas(&json_str);
291 let mut settings: Settings = serde_json::from_str(&clean)?;
292 settings.original_text = Some(content);
293 Ok(settings)
294 }
295
296 fn save_with_cst(&self, original: &str) -> Result<String, Box<dyn std::error::Error>> {
297 let root = CstRootNode::parse(original, &jsonc_parser::ParseOptions::default())
298 .map_err(|e| e.to_string())?;
299 let root_obj = root.object_value_or_set();
300
301 let value = serde_json::to_value(self)?;
302 let obj = value
303 .as_object()
304 .ok_or("settings serialized to non-object")?;
305
306 for (key, val) in obj {
307 let cst_input = serde_value_to_cst_input(val);
308 if let Some(prop) = root_obj.get(key) {
309 prop.set_value(cst_input);
310 } else {
311 root_obj.append(key, cst_input);
312 }
313 }
314
315 let props_to_remove: Vec<_> = root_obj
316 .properties()
317 .into_iter()
318 .filter(|prop| {
319 prop.name()
320 .and_then(|n| n.decoded_value().ok())
321 .is_some_and(|name| !obj.contains_key(&name))
322 })
323 .collect();
324 for prop in props_to_remove {
325 prop.remove();
326 }
327
328 Ok(root.to_string())
329 }
330
331 pub fn save(&self, path: Option<&Path>) -> Result<(), Box<dyn std::error::Error>> {
335 let path = match path {
336 Some(p) => p.to_path_buf(),
337 None => Self::settings_path()?,
338 };
339 let output = match &self.original_text {
340 Some(original) => self
341 .save_with_cst(original)
342 .or_else(|_| serde_json::to_string_pretty(self))?,
343 None => serde_json::to_string_pretty(self)?,
344 };
345 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
346 std::fs::create_dir_all(parent)?;
347 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
348 std::io::Write::write_all(&mut tmp, output.as_bytes())?;
349 std::io::Write::flush(&mut tmp)?;
350 tmp.persist(&path).map_err(|e| e.error)?;
351 Ok(())
352 }
353
354 pub fn upsert_priority(
357 &mut self,
358 command: &str,
359 provider: Option<ProviderConfig>,
360 model: Option<String>,
361 priority: i32,
362 ) {
363 for rule in &mut self.priority {
364 if rule.command == command && rule.provider == provider && rule.model == model {
365 rule.priority = priority;
366 return;
367 }
368 }
369 self.priority.push(PriorityRule {
370 command: command.to_string(),
371 provider,
372 model,
373 priority,
374 });
375 }
376
377 pub fn remove_priority(
379 &mut self,
380 command: &str,
381 provider: Option<&ProviderConfig>,
382 model: Option<&str>,
383 ) {
384 self.priority.retain(|rule| {
385 !(rule.command == command
386 && rule.provider.as_ref() == provider
387 && rule.model.as_deref() == model)
388 });
389 }
390
391 fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
392 let home = dirs::home_dir().ok_or("HOME directory not found")?;
393 let dir = home.join(".config").join("seher");
394 let jsonc_path = dir.join("settings.jsonc");
395 if jsonc_path.exists() {
396 return Ok(jsonc_path);
397 }
398 Ok(dir.join("settings.json"))
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 type TestResult = Result<(), Box<dyn std::error::Error>>;
407
408 fn sample_settings_path() -> PathBuf {
409 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
410 .join("examples")
411 .join("settings.json")
412 }
413
414 fn load_sample() -> Result<Settings, Box<dyn std::error::Error>> {
415 let content = std::fs::read_to_string(sample_settings_path())?;
416 let settings: Settings = serde_json::from_str(&content)?;
417 Ok(settings)
418 }
419
420 #[test]
421 fn test_parse_sample_settings() -> TestResult {
422 let settings = load_sample()?;
423
424 assert_eq!(settings.priority.len(), 4);
425 assert_eq!(settings.agents.len(), 4);
426 Ok(())
427 }
428
429 #[test]
430 fn test_sample_settings_priority_rules() -> TestResult {
431 let settings = load_sample()?;
432
433 assert_eq!(
434 settings.priority[0],
435 PriorityRule {
436 command: "opencode".to_string(),
437 provider: Some(ProviderConfig::Explicit("copilot".to_string())),
438 model: Some("high".to_string()),
439 priority: 100,
440 }
441 );
442 assert_eq!(
443 settings.priority[2],
444 PriorityRule {
445 command: "claude".to_string(),
446 provider: Some(ProviderConfig::None),
447 model: Some("medium".to_string()),
448 priority: 25,
449 }
450 );
451 Ok(())
452 }
453
454 #[test]
455 fn test_sample_settings_claude_agent() -> TestResult {
456 let settings = load_sample()?;
457
458 let claude = &settings.agents[0];
459 assert_eq!(claude.command, "claude");
460 assert_eq!(claude.args, ["--model", "{model}"]);
461
462 let models = claude.models.as_ref();
463 assert!(models.is_some());
464 let models = models.ok_or("models should be present")?;
465 assert_eq!(models.get("high").map(String::as_str), Some("opus"));
466 assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
467 assert_eq!(
468 claude.arg_maps.get("--danger").cloned(),
469 Some(vec![
470 "--permission-mode".to_string(),
471 "bypassPermissions".to_string(),
472 ])
473 );
474
475 assert!(claude.provider.is_none());
477 assert_eq!(claude.resolve_domain(), Some("claude.ai"));
478 Ok(())
479 }
480
481 #[test]
482 fn test_sample_settings_copilot_agent() -> TestResult {
483 let settings = load_sample()?;
484
485 let opencode = &settings.agents[1];
486 assert_eq!(opencode.command, "opencode");
487 assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
488
489 let models = opencode.models.as_ref().ok_or("models should be present")?;
490 assert_eq!(
491 models.get("high").map(String::as_str),
492 Some("github-copilot/gpt-5.4")
493 );
494 assert_eq!(
495 models.get("low").map(String::as_str),
496 Some("github-copilot/claude-haiku-4.5")
497 );
498
499 assert_eq!(
501 opencode.provider,
502 Some(ProviderConfig::Explicit("copilot".to_string()))
503 );
504 assert_eq!(opencode.resolve_domain(), Some("github.com"));
505 Ok(())
506 }
507
508 #[test]
509 fn test_sample_settings_fallback_agent() -> TestResult {
510 let settings = load_sample()?;
511
512 let fallback = &settings.agents[3];
513 assert_eq!(fallback.command, "claude");
514
515 assert_eq!(fallback.provider, Some(ProviderConfig::None));
517 assert_eq!(fallback.resolve_domain(), None);
518 Ok(())
519 }
520
521 #[test]
522 fn test_sample_settings_codex_agent() -> TestResult {
523 let settings = load_sample()?;
524
525 let codex = &settings.agents[2];
526 assert_eq!(codex.command, "codex");
527 assert!(codex.args.is_empty());
528 assert!(codex.models.is_none());
529 assert!(codex.provider.is_none());
530 assert_eq!(codex.resolve_domain(), Some("chatgpt.com"));
531 assert_eq!(codex.pre_command, ["git", "pull", "--rebase"]);
532 Ok(())
533 }
534
535 #[test]
536 fn test_provider_field_absent() -> TestResult {
537 let json = r#"{"agents": [{"command": "claude"}]}"#;
538 let settings: Settings = serde_json::from_str(json)?;
539
540 assert!(settings.agents[0].provider.is_none());
541 assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
542 assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
543 Ok(())
544 }
545
546 #[test]
547 fn test_provider_field_null() -> TestResult {
548 let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
549 let settings: Settings = serde_json::from_str(json)?;
550
551 assert_eq!(settings.agents[0].provider, Some(ProviderConfig::None));
552 assert_eq!(settings.agents[0].resolve_provider(), None);
553 assert_eq!(settings.agents[0].resolve_domain(), None);
554 Ok(())
555 }
556
557 #[test]
558 fn test_provider_field_string() -> TestResult {
559 let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
560 let settings: Settings = serde_json::from_str(json)?;
561
562 assert_eq!(
563 settings.agents[0].provider,
564 Some(ProviderConfig::Explicit("copilot".to_string()))
565 );
566 assert_eq!(settings.agents[0].resolve_provider(), Some("copilot"));
567 assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
568 Ok(())
569 }
570
571 #[test]
572 fn test_priority_defaults_to_empty() {
573 let settings = Settings::default();
574
575 assert!(settings.priority.is_empty());
576 }
577
578 #[test]
579 fn test_priority_defaults_to_zero_when_no_rule_matches() -> TestResult {
580 let json = r#"{"priority": [{"command": "claude", "model": "high", "priority": 10}], "agents": [{"command": "codex"}]}"#;
581 let settings: Settings = serde_json::from_str(json)?;
582
583 assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 0);
584 assert_eq!(
585 settings.priority_for_components("claude", Some("claude"), None),
586 0
587 );
588 Ok(())
589 }
590
591 #[test]
592 fn test_priority_matches_inferred_provider_and_model() -> TestResult {
593 let json = r#"{
594 "priority": [
595 {"command": "claude", "model": "high", "priority": 42}
596 ],
597 "agents": [{"command": "claude"}]
598 }"#;
599 let settings: Settings = serde_json::from_str(json)?;
600
601 assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 42);
602 Ok(())
603 }
604
605 #[test]
606 fn test_priority_matches_null_provider_for_fallback_agent() -> TestResult {
607 let json = r#"{
608 "priority": [
609 {"command": "claude", "provider": null, "model": "medium", "priority": 25}
610 ],
611 "agents": [{"command": "claude", "provider": null}]
612 }"#;
613 let settings: Settings = serde_json::from_str(json)?;
614
615 assert_eq!(
616 settings.priority_for(&settings.agents[0], Some("medium")),
617 25
618 );
619 Ok(())
620 }
621
622 #[test]
623 fn test_priority_supports_full_i32_range() -> TestResult {
624 let json = r#"{
625 "priority": [
626 {"command": "claude", "model": "high", "priority": 2147483647},
627 {"command": "claude", "provider": null, "priority": -2147483648}
628 ],
629 "agents": [
630 {"command": "claude"},
631 {"command": "claude", "provider": null}
632 ]
633 }"#;
634 let settings: Settings = serde_json::from_str(json)?;
635
636 assert_eq!(
637 settings.priority_for(&settings.agents[0], Some("high")),
638 i32::MAX
639 );
640 assert_eq!(settings.priority_for(&settings.agents[1], None), i32::MIN);
641 Ok(())
642 }
643
644 #[test]
645 fn test_command_codex_resolves_chatgpt_domain() -> TestResult {
646 let json = r#"{"agents": [{"command": "codex"}]}"#;
647 let settings: Settings = serde_json::from_str(json)?;
648
649 assert!(settings.agents[0].provider.is_none());
650 assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
651 Ok(())
652 }
653
654 #[test]
655 fn test_provider_field_codex_string() -> TestResult {
656 let json = r#"{"agents": [{"command": "opencode", "provider": "codex"}]}"#;
657 let settings: Settings = serde_json::from_str(json)?;
658
659 assert_eq!(
660 settings.agents[0].provider,
661 Some(ProviderConfig::Explicit("codex".to_string()))
662 );
663 assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
664 Ok(())
665 }
666
667 #[test]
668 fn test_provider_unknown_string() -> TestResult {
669 let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
670 let settings: Settings = serde_json::from_str(json)?;
671
672 assert_eq!(
673 settings.agents[0].provider,
674 Some(ProviderConfig::Explicit("unknown".to_string()))
675 );
676 assert_eq!(settings.agents[0].resolve_domain(), None);
677 Ok(())
678 }
679
680 #[test]
681 fn test_parse_minimal_settings_without_models() -> TestResult {
682 let json = r#"{"agents": [{"command": "claude"}]}"#;
683 let settings: Settings = serde_json::from_str(json)?;
684
685 assert_eq!(settings.agents.len(), 1);
686 assert_eq!(settings.agents[0].command, "claude");
687 assert!(settings.agents[0].args.is_empty());
688 assert!(settings.agents[0].models.is_none());
689 assert!(settings.agents[0].arg_maps.is_empty());
690 Ok(())
691 }
692
693 #[test]
694 fn test_parse_settings_with_env() -> TestResult {
695 let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
696 let settings: Settings = serde_json::from_str(json)?;
697
698 let env = settings.agents[0]
699 .env
700 .as_ref()
701 .ok_or("env should be present")?;
702 assert_eq!(
703 env.get("ANTHROPIC_API_KEY").map(String::as_str),
704 Some("sk-test")
705 );
706 assert_eq!(env.get("CLAUDE_CODE_MAX_HOURS").map(String::as_str), None);
707 assert_eq!(
708 env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
709 Some("100")
710 );
711 Ok(())
712 }
713
714 #[test]
715 fn test_parse_settings_with_args_no_models() -> TestResult {
716 let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
717 let settings: Settings = serde_json::from_str(json)?;
718
719 assert_eq!(
720 settings.agents[0].args,
721 ["--permission-mode", "bypassPermissions"]
722 );
723 assert!(settings.agents[0].models.is_none());
724 assert!(settings.agents[0].arg_maps.is_empty());
725 Ok(())
726 }
727
728 #[test]
729 fn test_parse_jsonc_with_comments() -> TestResult {
730 let jsonc = r#"{
731 // This is a comment
732 "agents": [
733 {
734 "command": "claude", /* inline comment */
735 "args": ["--model", "{model}"]
736 }
737 ]
738 }"#;
739 let stripped = json_comments::StripComments::new(jsonc.as_bytes());
740 let settings: Settings = serde_json::from_reader(stripped)?;
741 assert_eq!(settings.agents.len(), 1);
742 assert_eq!(settings.agents[0].command, "claude");
743 Ok(())
744 }
745
746 #[test]
747 fn test_parse_jsonc_with_trailing_commas() -> TestResult {
748 let jsonc = r#"{
749 // trailing commas
750 "agents": [
751 {
752 "command": "claude",
753 "args": ["--model", "{model}"],
754 },
755 ]
756 }"#;
757 let mut stripped = json_comments::StripComments::new(jsonc.as_bytes());
758 let mut json_str = String::new();
759 std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
760 let clean = strip_trailing_commas(&json_str);
761 let settings: Settings = serde_json::from_str(&clean)?;
762 assert_eq!(settings.agents.len(), 1);
763 assert_eq!(settings.agents[0].command, "claude");
764 Ok(())
765 }
766
767 #[test]
768 fn test_parse_settings_with_arg_maps() -> TestResult {
769 let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
770 let settings: Settings = serde_json::from_str(json)?;
771
772 assert_eq!(
773 settings.agents[0].arg_maps.get("--danger").cloned(),
774 Some(vec![
775 "--permission-mode".to_string(),
776 "bypassPermissions".to_string(),
777 ])
778 );
779 Ok(())
780 }
781
782 #[test]
783 fn test_parse_settings_with_openrouter_management_key() -> TestResult {
784 let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
786
787 let settings: Settings = serde_json::from_str(json)?;
789
790 assert_eq!(
792 settings.agents[0].openrouter_management_key.as_deref(),
793 Some("sk-or-v1-abc123")
794 );
795 Ok(())
796 }
797
798 #[test]
799 fn test_openrouter_management_key_defaults_to_none_when_absent() -> TestResult {
800 let json = r#"{"agents": [{"command": "claude"}]}"#;
802
803 let settings: Settings = serde_json::from_str(json)?;
805
806 assert!(settings.agents[0].openrouter_management_key.is_none());
808 Ok(())
809 }
810
811 #[test]
812 fn test_openrouter_provider_resolves_provider_but_not_domain() -> TestResult {
813 let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
815
816 let settings: Settings = serde_json::from_str(json)?;
818
819 assert_eq!(settings.agents[0].resolve_provider(), Some("openrouter"));
822 assert_eq!(settings.agents[0].resolve_domain(), None);
823 Ok(())
824 }
825
826 #[test]
827 fn test_parse_settings_with_pre_command() -> TestResult {
828 let json =
829 r#"{"agents": [{"command": "claude", "pre_command": ["git", "pull", "--rebase"]}]}"#;
830 let settings: Settings = serde_json::from_str(json)?;
831
832 assert_eq!(settings.agents[0].pre_command, ["git", "pull", "--rebase"]);
833 Ok(())
834 }
835
836 #[test]
837 fn test_pre_command_defaults_to_empty_when_absent() -> TestResult {
838 let json = r#"{"agents": [{"command": "claude"}]}"#;
839 let settings: Settings = serde_json::from_str(json)?;
840
841 assert!(settings.agents[0].pre_command.is_empty());
842 Ok(())
843 }
844
845 #[test]
846 fn test_openrouter_management_key_is_ignored_for_other_providers() -> TestResult {
847 let json = r#"{"agents": [{"command": "claude", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
849
850 let settings: Settings = serde_json::from_str(json)?;
852
853 assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
855 assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
856 Ok(())
857 }
858
859 #[test]
862 fn test_serialize_roundtrip_sample_settings() -> TestResult {
863 let settings = load_sample()?;
864 let json = serde_json::to_string_pretty(&settings)?;
865 let reparsed: Settings = serde_json::from_str(&json)?;
866
867 assert_eq!(reparsed.agents.len(), settings.agents.len());
868 assert_eq!(reparsed.priority.len(), settings.priority.len());
869 assert_eq!(reparsed.agents[0].command, settings.agents[0].command);
870 Ok(())
871 }
872
873 #[test]
874 fn test_serialize_skips_empty_args() -> TestResult {
875 let json = r#"{"agents": [{"command": "claude"}]}"#;
876 let settings: Settings = serde_json::from_str(json)?;
877 let out = serde_json::to_string(&settings)?;
878 let val: serde_json::Value = serde_json::from_str(&out)?;
879
880 assert!(val["agents"][0]["args"].is_null());
881 Ok(())
882 }
883
884 #[test]
885 fn test_serialize_null_provider_roundtrip() -> TestResult {
886 let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
887 let settings: Settings = serde_json::from_str(json)?;
888 let out = serde_json::to_string(&settings)?;
889 let val: serde_json::Value = serde_json::from_str(&out)?;
890
891 assert!(val["agents"][0]["provider"].is_null());
892 Ok(())
893 }
894
895 #[test]
896 fn test_serialize_inferred_provider_skipped() -> TestResult {
897 let json = r#"{"agents": [{"command": "claude"}]}"#;
898 let settings: Settings = serde_json::from_str(json)?;
899 let out = serde_json::to_string(&settings)?;
900 let val: serde_json::Value = serde_json::from_str(&out)?;
901
902 assert!(val["agents"][0]["provider"].is_null());
904 Ok(())
905 }
906
907 #[test]
908 fn test_upsert_priority_creates_new_rule() {
909 let mut settings = Settings::default();
910 settings.upsert_priority("claude", None, Some("high".to_string()), 42);
911
912 assert_eq!(settings.priority.len(), 1);
913 assert_eq!(settings.priority[0].priority, 42);
914 assert_eq!(settings.priority[0].model.as_deref(), Some("high"));
915 }
916
917 #[test]
918 fn test_upsert_priority_updates_existing_rule() {
919 let mut settings = Settings::default();
920 settings.upsert_priority("claude", None, Some("high".to_string()), 10);
921 settings.upsert_priority("claude", None, Some("high".to_string()), 99);
922
923 assert_eq!(settings.priority.len(), 1);
924 assert_eq!(settings.priority[0].priority, 99);
925 }
926
927 #[test]
928 fn test_remove_priority_removes_matching_rule() {
929 let mut settings = Settings::default();
930 settings.upsert_priority("claude", None, Some("high".to_string()), 10);
931 settings.upsert_priority("claude", None, Some("low".to_string()), 5);
932 settings.remove_priority("claude", None, Some("high"));
933
934 assert_eq!(settings.priority.len(), 1);
935 assert_eq!(settings.priority[0].model.as_deref(), Some("low"));
936 }
937
938 #[test]
939 fn test_save_and_reload() -> TestResult {
940 let settings = load_sample()?;
941 let tmp = tempfile::NamedTempFile::new()?;
942 settings.save(Some(tmp.path()))?;
943
944 let content = std::fs::read_to_string(tmp.path())?;
945 let reloaded: Settings = serde_json::from_str(&content)?;
946
947 assert_eq!(reloaded.agents.len(), settings.agents.len());
948 assert_eq!(reloaded.priority.len(), settings.priority.len());
949 Ok(())
950 }
951
952 #[test]
953 fn test_save_preserves_comments() -> TestResult {
954 let jsonc = r#"{
955 // This is a top-level comment
956 "agents": [
957 {"command": "claude"}
958 ]
959}"#;
960 let tmp = tempfile::NamedTempFile::new()?;
961 std::fs::write(tmp.path(), jsonc)?;
962
963 let settings = Settings::load(Some(tmp.path()))?;
964 settings.save(Some(tmp.path()))?;
965
966 let content = std::fs::read_to_string(tmp.path())?;
967 assert!(content.contains("// This is a top-level comment"));
968 assert!(content.contains("claude"));
969 Ok(())
970 }
971
972 #[test]
973 fn test_save_plain_json_roundtrip_via_load() -> TestResult {
974 let json = r#"{"agents": [{"command": "claude"}]}"#;
975 let tmp = tempfile::NamedTempFile::new()?;
976 std::fs::write(tmp.path(), json)?;
977
978 let settings = Settings::load(Some(tmp.path()))?;
979 settings.save(Some(tmp.path()))?;
980
981 let content = std::fs::read_to_string(tmp.path())?;
982 let reloaded = Settings::load(Some(tmp.path()))?;
983 assert_eq!(reloaded.agents.len(), 1);
984 assert_eq!(reloaded.agents[0].command, "claude");
985 let _: serde_json::Value = serde_json::from_str(&content)?;
987 Ok(())
988 }
989
990 #[test]
991 fn test_save_with_added_agent_preserves_comments() -> TestResult {
992 let jsonc = r#"{
993 // Top comment
994 "agents": [
995 {"command": "claude"}
996 ]
997}"#;
998 let tmp = tempfile::NamedTempFile::new()?;
999 std::fs::write(tmp.path(), jsonc)?;
1000
1001 let mut settings = Settings::load(Some(tmp.path()))?;
1002 settings.agents.push(AgentConfig {
1003 command: "codex".to_string(),
1004 args: vec![],
1005 models: None,
1006 arg_maps: HashMap::new(),
1007 env: None,
1008 provider: None,
1009 openrouter_management_key: None,
1010 pre_command: vec![],
1011 });
1012 settings.save(Some(tmp.path()))?;
1013
1014 let content = std::fs::read_to_string(tmp.path())?;
1015 assert!(content.contains("// Top comment"));
1016 assert!(content.contains("codex"));
1017 Ok(())
1018 }
1019
1020 #[test]
1021 fn test_serde_value_to_cst_input_variants() {
1022 use jsonc_parser::cst::CstInputValue;
1023
1024 assert!(matches!(
1025 serde_value_to_cst_input(&serde_json::Value::Null),
1026 CstInputValue::Null
1027 ));
1028 assert!(matches!(
1029 serde_value_to_cst_input(&serde_json::Value::Bool(true)),
1030 CstInputValue::Bool(true)
1031 ));
1032 assert!(matches!(
1033 serde_value_to_cst_input(&serde_json::Value::String("hi".to_string())),
1034 CstInputValue::String(s) if s == "hi"
1035 ));
1036 assert!(matches!(
1037 serde_value_to_cst_input(&serde_json::json!(42)),
1038 CstInputValue::Number(n) if n == "42"
1039 ));
1040 assert!(matches!(
1041 serde_value_to_cst_input(&serde_json::json!([])),
1042 CstInputValue::Array(v) if v.is_empty()
1043 ));
1044 assert!(matches!(
1045 serde_value_to_cst_input(&serde_json::json!({})),
1046 CstInputValue::Object(v) if v.is_empty()
1047 ));
1048 }
1049}