Skip to main content

seher/config/
mod.rs

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/// Represents the three possible states of the `provider` field:
16/// - `Inferred`: field absent -> provider is inferred from the command name
17/// - `Explicit(name)`: field has a string value -> use that provider name
18/// - `None`: field is `null` -> no provider (fallback agent)
19#[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                // trailing comma: skip it
240            } 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    /// # Errors
273    ///
274    /// Returns an error if the settings file cannot be read or parsed.
275    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    /// # Errors
332    ///
333    /// Returns an error if serialization or file writing fails.
334    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    /// Upsert a `PriorityRule`. If a matching rule (command + provider + model) already exists,
355    /// its priority is updated. Otherwise a new rule is appended.
356    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    /// Remove a `PriorityRule` matching the given (command, provider, model) triple.
378    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        // no provider field -> None (inferred from command name)
476        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        // provider: "copilot" -> Some(Explicit("copilot"))
500        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        // provider: null -> Some(ProviderConfig::None) (fallback)
516        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        // Given: agent config with openrouter provider and management key
785        let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
786
787        // When: parsed
788        let settings: Settings = serde_json::from_str(json)?;
789
790        // Then: key is correctly deserialized
791        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        // Given: agent config without openrouter_management_key field
801        let json = r#"{"agents": [{"command": "claude"}]}"#;
802
803        // When: parsed
804        let settings: Settings = serde_json::from_str(json)?;
805
806        // Then: key defaults to None
807        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        // Given: agent with explicit "openrouter" provider (no cookie-based auth)
814        let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
815
816        // When: provider and domain resolved
817        let settings: Settings = serde_json::from_str(json)?;
818
819        // Then: provider resolves to "openrouter" but domain is None
820        // (OpenRouter does not use browser cookies)
821        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        // Given: claude agent config that happens to have openrouter_management_key set
848        let json = r#"{"agents": [{"command": "claude", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
849
850        // When: parsed
851        let settings: Settings = serde_json::from_str(json)?;
852
853        // Then: provider resolution is unaffected by the presence of openrouter_management_key
854        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    // -- Serialize tests ------------------------------------------------------
860
861    #[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        // provider field absent when inferred
903        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        // Should be valid JSON (parseable)
986        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}