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
12fn deserialize_provider<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
13where
14    D: serde::Deserializer<'de>,
15{
16    let opt: Option<String> = serde::Deserialize::deserialize(deserializer)?;
17    Ok(Some(opt))
18}
19
20#[derive(Debug, Deserialize, Clone)]
21pub struct AgentConfig {
22    pub command: String,
23    #[serde(default)]
24    pub args: Vec<String>,
25    #[serde(default)]
26    pub models: Option<HashMap<String, String>>,
27    #[serde(default)]
28    pub arg_maps: HashMap<String, Vec<String>>,
29    #[serde(default)]
30    pub env: Option<HashMap<String, String>>,
31    #[serde(default, deserialize_with = "deserialize_provider")]
32    pub provider: Option<Option<String>>,
33}
34
35#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
36pub struct PriorityRule {
37    pub command: String,
38    #[serde(default, deserialize_with = "deserialize_provider")]
39    pub provider: Option<Option<String>>,
40    #[serde(default)]
41    pub model: Option<String>,
42    pub priority: i32,
43}
44
45fn command_to_provider(command: &str) -> Option<&str> {
46    match command {
47        "claude" => Some("claude"),
48        "codex" => Some("codex"),
49        "copilot" => Some("copilot"),
50        _ => None,
51    }
52}
53
54fn resolve_provider<'a>(command: &'a str, provider: &'a Option<Option<String>>) -> Option<&'a str> {
55    match provider {
56        Some(Some(provider)) => Some(provider.as_str()),
57        Some(None) => None,
58        None => command_to_provider(command),
59    }
60}
61
62fn provider_to_domain(provider: &str) -> Option<&str> {
63    match provider {
64        "claude" => Some("claude.ai"),
65        "codex" => Some("chatgpt.com"),
66        "copilot" => Some("github.com"),
67        _ => None,
68    }
69}
70
71impl AgentConfig {
72    pub fn resolve_provider(&self) -> Option<&str> {
73        resolve_provider(&self.command, &self.provider)
74    }
75
76    pub fn resolve_domain(&self) -> Option<&str> {
77        self.resolve_provider().and_then(provider_to_domain)
78    }
79}
80
81impl PriorityRule {
82    pub fn resolve_provider(&self) -> Option<&str> {
83        resolve_provider(&self.command, &self.provider)
84    }
85
86    pub fn matches(&self, command: &str, provider: Option<&str>, model: Option<&str>) -> bool {
87        self.command == command
88            && self.resolve_provider() == provider
89            && self.model.as_deref() == model
90    }
91}
92
93impl Default for Settings {
94    fn default() -> Self {
95        Self {
96            priority: vec![],
97            agents: vec![AgentConfig {
98                command: "claude".to_string(),
99                args: vec![],
100                models: None,
101                arg_maps: HashMap::new(),
102                env: None,
103                provider: None,
104            }],
105        }
106    }
107}
108
109fn strip_trailing_commas(s: &str) -> String {
110    let chars: Vec<char> = s.chars().collect();
111    let mut result = String::with_capacity(s.len());
112    let mut i = 0;
113    let mut in_string = false;
114
115    while i < chars.len() {
116        let c = chars[i];
117
118        if in_string {
119            result.push(c);
120            if c == '\\' && i + 1 < chars.len() {
121                i += 1;
122                result.push(chars[i]);
123            } else if c == '"' {
124                in_string = false;
125            }
126        } else if c == '"' {
127            in_string = true;
128            result.push(c);
129        } else if c == ',' {
130            let mut j = i + 1;
131            while j < chars.len() && chars[j].is_whitespace() {
132                j += 1;
133            }
134            if j < chars.len() && (chars[j] == ']' || chars[j] == '}') {
135                // trailing comma: skip it
136            } else {
137                result.push(c);
138            }
139        } else {
140            result.push(c);
141        }
142
143        i += 1;
144    }
145
146    result
147}
148
149impl Settings {
150    pub fn priority_for(&self, agent: &AgentConfig, model: Option<&str>) -> i32 {
151        self.priority_for_components(&agent.command, agent.resolve_provider(), model)
152    }
153
154    pub fn priority_for_components(
155        &self,
156        command: &str,
157        provider: Option<&str>,
158        model: Option<&str>,
159    ) -> i32 {
160        self.priority
161            .iter()
162            .find(|rule| rule.matches(command, provider, model))
163            .map_or(0, |rule| rule.priority)
164    }
165
166    pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
167        let path = match path {
168            Some(p) => p.to_path_buf(),
169            None => Self::settings_path()?,
170        };
171        let content = match std::fs::read_to_string(&path) {
172            Ok(c) => c,
173            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
174                return Ok(Settings::default());
175            }
176            Err(e) => return Err(e.into()),
177        };
178        let mut stripped = json_comments::StripComments::new(content.as_bytes());
179        let mut json_str = String::new();
180        std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
181        let clean = strip_trailing_commas(&json_str);
182        let settings: Settings = serde_json::from_str(&clean)?;
183        Ok(settings)
184    }
185
186    fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
187        let home = dirs::home_dir().ok_or("HOME directory not found")?;
188        let dir = home.join(".config").join("seher");
189        let jsonc_path = dir.join("settings.jsonc");
190        if jsonc_path.exists() {
191            return Ok(jsonc_path);
192        }
193        Ok(dir.join("settings.json"))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn sample_settings_path() -> PathBuf {
202        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
203            .join("examples")
204            .join("settings.json")
205    }
206
207    #[test]
208    fn test_parse_sample_settings() {
209        let content = std::fs::read_to_string(sample_settings_path())
210            .expect("examples/settings.json not found");
211        let settings: Settings = serde_json::from_str(&content).expect("failed to parse settings");
212
213        assert_eq!(settings.priority.len(), 4);
214        assert_eq!(settings.agents.len(), 4);
215    }
216
217    #[test]
218    fn test_sample_settings_priority_rules() {
219        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
220        let settings: Settings = serde_json::from_str(&content).unwrap();
221
222        assert_eq!(
223            settings.priority[0],
224            PriorityRule {
225                command: "opencode".to_string(),
226                provider: Some(Some("copilot".to_string())),
227                model: Some("high".to_string()),
228                priority: 100,
229            }
230        );
231        assert_eq!(
232            settings.priority[2],
233            PriorityRule {
234                command: "claude".to_string(),
235                provider: Some(None),
236                model: Some("medium".to_string()),
237                priority: 25,
238            }
239        );
240    }
241
242    #[test]
243    fn test_sample_settings_claude_agent() {
244        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
245        let settings: Settings = serde_json::from_str(&content).unwrap();
246
247        let claude = &settings.agents[0];
248        assert_eq!(claude.command, "claude");
249        assert_eq!(claude.args, ["--model", "{model}"]);
250
251        let models = claude.models.as_ref().expect("models should be present");
252        assert_eq!(models.get("high").map(String::as_str), Some("opus"));
253        assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
254        assert_eq!(
255            claude.arg_maps.get("--danger").cloned(),
256            Some(vec![
257                "--permission-mode".to_string(),
258                "bypassPermissions".to_string(),
259            ])
260        );
261
262        // no provider field → None (inferred from command name)
263        assert!(claude.provider.is_none());
264        assert_eq!(claude.resolve_domain(), Some("claude.ai"));
265    }
266
267    #[test]
268    fn test_sample_settings_copilot_agent() {
269        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
270        let settings: Settings = serde_json::from_str(&content).unwrap();
271
272        let opencode = &settings.agents[1];
273        assert_eq!(opencode.command, "opencode");
274        assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
275
276        let models = opencode.models.as_ref().expect("models should be present");
277        assert_eq!(
278            models.get("high").map(String::as_str),
279            Some("github-copilot/gpt-5.4")
280        );
281        assert_eq!(
282            models.get("low").map(String::as_str),
283            Some("github-copilot/claude-haiku-4.5")
284        );
285
286        // provider: "copilot" → Some(Some("copilot"))
287        assert_eq!(opencode.provider, Some(Some("copilot".to_string())));
288        assert_eq!(opencode.resolve_domain(), Some("github.com"));
289    }
290
291    #[test]
292    fn test_sample_settings_fallback_agent() {
293        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
294        let settings: Settings = serde_json::from_str(&content).unwrap();
295
296        let fallback = &settings.agents[3];
297        assert_eq!(fallback.command, "claude");
298
299        // provider: null → Some(None) (fallback)
300        assert_eq!(fallback.provider, Some(None));
301        assert_eq!(fallback.resolve_domain(), None);
302    }
303
304    #[test]
305    fn test_sample_settings_codex_agent() {
306        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
307        let settings: Settings = serde_json::from_str(&content).unwrap();
308
309        let codex = &settings.agents[2];
310        assert_eq!(codex.command, "codex");
311        assert!(codex.args.is_empty());
312        assert!(codex.models.is_none());
313        assert!(codex.provider.is_none());
314        assert_eq!(codex.resolve_domain(), Some("chatgpt.com"));
315    }
316
317    #[test]
318    fn test_provider_field_absent() {
319        let json = r#"{"agents": [{"command": "claude"}]}"#;
320        let settings: Settings = serde_json::from_str(json).unwrap();
321
322        assert!(settings.agents[0].provider.is_none());
323        assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
324        assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
325    }
326
327    #[test]
328    fn test_provider_field_null() {
329        let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
330        let settings: Settings = serde_json::from_str(json).unwrap();
331
332        assert_eq!(settings.agents[0].provider, Some(None));
333        assert_eq!(settings.agents[0].resolve_provider(), None);
334        assert_eq!(settings.agents[0].resolve_domain(), None);
335    }
336
337    #[test]
338    fn test_provider_field_string() {
339        let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
340        let settings: Settings = serde_json::from_str(json).unwrap();
341
342        assert_eq!(
343            settings.agents[0].provider,
344            Some(Some("copilot".to_string()))
345        );
346        assert_eq!(settings.agents[0].resolve_provider(), Some("copilot"));
347        assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
348    }
349
350    #[test]
351    fn test_priority_defaults_to_empty() {
352        let settings = Settings::default();
353
354        assert!(settings.priority.is_empty());
355    }
356
357    #[test]
358    fn test_priority_defaults_to_zero_when_no_rule_matches() {
359        let json = r#"{"priority": [{"command": "claude", "model": "high", "priority": 10}], "agents": [{"command": "codex"}]}"#;
360        let settings: Settings = serde_json::from_str(json).unwrap();
361
362        assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 0);
363        assert_eq!(
364            settings.priority_for_components("claude", Some("claude"), None),
365            0
366        );
367    }
368
369    #[test]
370    fn test_priority_matches_inferred_provider_and_model() {
371        let json = r#"{
372            "priority": [
373                {"command": "claude", "model": "high", "priority": 42}
374            ],
375            "agents": [{"command": "claude"}]
376        }"#;
377        let settings: Settings = serde_json::from_str(json).unwrap();
378
379        assert_eq!(settings.priority_for(&settings.agents[0], Some("high")), 42);
380    }
381
382    #[test]
383    fn test_priority_matches_null_provider_for_fallback_agent() {
384        let json = r#"{
385            "priority": [
386                {"command": "claude", "provider": null, "model": "medium", "priority": 25}
387            ],
388            "agents": [{"command": "claude", "provider": null}]
389        }"#;
390        let settings: Settings = serde_json::from_str(json).unwrap();
391
392        assert_eq!(
393            settings.priority_for(&settings.agents[0], Some("medium")),
394            25
395        );
396    }
397
398    #[test]
399    fn test_priority_supports_full_i32_range() {
400        let json = r#"{
401            "priority": [
402                {"command": "claude", "model": "high", "priority": 2147483647},
403                {"command": "claude", "provider": null, "priority": -2147483648}
404            ],
405            "agents": [
406                {"command": "claude"},
407                {"command": "claude", "provider": null}
408            ]
409        }"#;
410        let settings: Settings = serde_json::from_str(json).unwrap();
411
412        assert_eq!(
413            settings.priority_for(&settings.agents[0], Some("high")),
414            i32::MAX
415        );
416        assert_eq!(settings.priority_for(&settings.agents[1], None), i32::MIN);
417    }
418
419    #[test]
420    fn test_command_codex_resolves_chatgpt_domain() {
421        let json = r#"{"agents": [{"command": "codex"}]}"#;
422        let settings: Settings = serde_json::from_str(json).unwrap();
423
424        assert!(settings.agents[0].provider.is_none());
425        assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
426    }
427
428    #[test]
429    fn test_provider_field_codex_string() {
430        let json = r#"{"agents": [{"command": "opencode", "provider": "codex"}]}"#;
431        let settings: Settings = serde_json::from_str(json).unwrap();
432
433        assert_eq!(settings.agents[0].provider, Some(Some("codex".to_string())));
434        assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
435    }
436
437    #[test]
438    fn test_provider_unknown_string() {
439        let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
440        let settings: Settings = serde_json::from_str(json).unwrap();
441
442        assert_eq!(
443            settings.agents[0].provider,
444            Some(Some("unknown".to_string()))
445        );
446        assert_eq!(settings.agents[0].resolve_domain(), None);
447    }
448
449    #[test]
450    fn test_parse_minimal_settings_without_models() {
451        let json = r#"{"agents": [{"command": "claude"}]}"#;
452        let settings: Settings =
453            serde_json::from_str(json).expect("failed to parse minimal settings");
454
455        assert_eq!(settings.agents.len(), 1);
456        assert_eq!(settings.agents[0].command, "claude");
457        assert!(settings.agents[0].args.is_empty());
458        assert!(settings.agents[0].models.is_none());
459        assert!(settings.agents[0].arg_maps.is_empty());
460    }
461
462    #[test]
463    fn test_parse_settings_with_env() {
464        let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
465        let settings: Settings = serde_json::from_str(json).unwrap();
466
467        let env = settings.agents[0]
468            .env
469            .as_ref()
470            .expect("env should be present");
471        assert_eq!(
472            env.get("ANTHROPIC_API_KEY").map(String::as_str),
473            Some("sk-test")
474        );
475        assert_eq!(
476            env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
477            Some("100")
478        );
479    }
480
481    #[test]
482    fn test_parse_settings_with_args_no_models() {
483        let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
484        let settings: Settings = serde_json::from_str(json).unwrap();
485
486        assert_eq!(
487            settings.agents[0].args,
488            ["--permission-mode", "bypassPermissions"]
489        );
490        assert!(settings.agents[0].models.is_none());
491        assert!(settings.agents[0].arg_maps.is_empty());
492    }
493
494    #[test]
495    fn test_parse_jsonc_with_comments() {
496        let jsonc = r#"{
497            // This is a comment
498            "agents": [
499                {
500                    "command": "claude", /* inline comment */
501                    "args": ["--model", "{model}"]
502                }
503            ]
504        }"#;
505        let stripped = json_comments::StripComments::new(jsonc.as_bytes());
506        let settings: Settings = serde_json::from_reader(stripped).unwrap();
507        assert_eq!(settings.agents.len(), 1);
508        assert_eq!(settings.agents[0].command, "claude");
509    }
510
511    #[test]
512    fn test_parse_jsonc_with_trailing_commas() {
513        let jsonc = r#"{
514            // trailing commas
515            "agents": [
516                {
517                    "command": "claude",
518                    "args": ["--model", "{model}"],
519                },
520            ]
521        }"#;
522        let mut stripped = json_comments::StripComments::new(jsonc.as_bytes());
523        let mut json_str = String::new();
524        std::io::Read::read_to_string(&mut stripped, &mut json_str).unwrap();
525        let clean = strip_trailing_commas(&json_str);
526        let settings: Settings = serde_json::from_str(&clean).unwrap();
527        assert_eq!(settings.agents.len(), 1);
528        assert_eq!(settings.agents[0].command, "claude");
529    }
530
531    #[test]
532    fn test_parse_settings_with_arg_maps() {
533        let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
534        let settings: Settings = serde_json::from_str(json).unwrap();
535
536        assert_eq!(
537            settings.agents[0].arg_maps.get("--danger").cloned(),
538            Some(vec![
539                "--permission-mode".to_string(),
540                "bypassPermissions".to_string(),
541            ])
542        );
543    }
544}