Skip to main content

seher/config/
mod.rs

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/// Represents the three possible states of the `provider` field:
13/// - `Inferred`: field absent → provider is inferred from the command name
14/// - `Explicit(name)`: field has a string value → use that provider name
15/// - `None`: field is `null` → no provider (fallback agent)
16#[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                // trailing comma: skip it
219            } 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    /// # Errors
252    ///
253    /// Returns an error if the settings file cannot be read or parsed.
254    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    /// # Errors
275    ///
276    /// Returns an error if serialization or file writing fails.
277    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    /// Upsert a `PriorityRule`. If a matching rule (command + provider + model) already exists,
293    /// its priority is updated. Otherwise a new rule is appended.
294    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    /// Remove a `PriorityRule` matching the given (command, provider, model) triple.
316    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        // no provider field → None (inferred from command name)
414        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        // provider: "copilot" → Some(Explicit("copilot"))
438        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        // provider: null → Some(ProviderConfig::None) (fallback)
454        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        // Given: agent config with openrouter provider and management key
723        let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
724
725        // When: parsed
726        let settings: Settings = serde_json::from_str(json)?;
727
728        // Then: key is correctly deserialized
729        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        // Given: agent config without openrouter_management_key field
739        let json = r#"{"agents": [{"command": "claude"}]}"#;
740
741        // When: parsed
742        let settings: Settings = serde_json::from_str(json)?;
743
744        // Then: key defaults to None
745        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        // Given: agent with explicit "openrouter" provider (no cookie-based auth)
752        let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
753
754        // When: provider and domain resolved
755        let settings: Settings = serde_json::from_str(json)?;
756
757        // Then: provider resolves to "openrouter" but domain is None
758        // (OpenRouter does not use browser cookies)
759        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        // Given: claude agent config that happens to have openrouter_management_key set
786        let json = r#"{"agents": [{"command": "claude", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
787
788        // When: parsed
789        let settings: Settings = serde_json::from_str(json)?;
790
791        // Then: provider resolution is unaffected by the presence of openrouter_management_key
792        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    // ── Serialize tests ──────────────────────────────────────────────────────
798
799    #[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        // provider field absent when inferred
841        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}