Skip to main content

seher/config/
mod.rs

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