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