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    pub agents: Vec<AgentConfig>,
8}
9
10fn deserialize_provider<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
11where
12    D: serde::Deserializer<'de>,
13{
14    let opt: Option<String> = serde::Deserialize::deserialize(deserializer)?;
15    Ok(Some(opt))
16}
17
18#[derive(Debug, Deserialize, Clone)]
19pub struct AgentConfig {
20    pub command: String,
21    #[serde(default)]
22    pub args: Vec<String>,
23    #[serde(default)]
24    pub models: Option<HashMap<String, String>>,
25    #[serde(default)]
26    pub arg_maps: HashMap<String, Vec<String>>,
27    #[serde(default)]
28    pub env: Option<HashMap<String, String>>,
29    #[serde(default, deserialize_with = "deserialize_provider")]
30    pub provider: Option<Option<String>>,
31}
32
33fn provider_to_domain(provider: &str) -> Option<&str> {
34    match provider {
35        "claude" => Some("claude.ai"),
36        "codex" => Some("chatgpt.com"),
37        "copilot" => Some("github.com"),
38        _ => None,
39    }
40}
41
42impl AgentConfig {
43    pub fn resolve_domain(&self) -> Option<&str> {
44        match &self.provider {
45            Some(Some(p)) => provider_to_domain(p),
46            Some(None) => None,
47            None => provider_to_domain(&self.command),
48        }
49    }
50}
51
52impl Default for Settings {
53    fn default() -> Self {
54        Self {
55            agents: vec![AgentConfig {
56                command: "claude".to_string(),
57                args: vec![],
58                models: None,
59                arg_maps: HashMap::new(),
60                env: None,
61                provider: None,
62            }],
63        }
64    }
65}
66
67fn strip_trailing_commas(s: &str) -> String {
68    let chars: Vec<char> = s.chars().collect();
69    let mut result = String::with_capacity(s.len());
70    let mut i = 0;
71    let mut in_string = false;
72
73    while i < chars.len() {
74        let c = chars[i];
75
76        if in_string {
77            result.push(c);
78            if c == '\\' && i + 1 < chars.len() {
79                i += 1;
80                result.push(chars[i]);
81            } else if c == '"' {
82                in_string = false;
83            }
84        } else if c == '"' {
85            in_string = true;
86            result.push(c);
87        } else if c == ',' {
88            let mut j = i + 1;
89            while j < chars.len() && chars[j].is_whitespace() {
90                j += 1;
91            }
92            if j < chars.len() && (chars[j] == ']' || chars[j] == '}') {
93                // trailing comma: skip it
94            } else {
95                result.push(c);
96            }
97        } else {
98            result.push(c);
99        }
100
101        i += 1;
102    }
103
104    result
105}
106
107impl Settings {
108    pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
109        let path = match path {
110            Some(p) => p.to_path_buf(),
111            None => Self::settings_path()?,
112        };
113        let content = match std::fs::read_to_string(&path) {
114            Ok(c) => c,
115            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
116                return Ok(Settings::default());
117            }
118            Err(e) => return Err(e.into()),
119        };
120        let mut stripped = json_comments::StripComments::new(content.as_bytes());
121        let mut json_str = String::new();
122        std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
123        let clean = strip_trailing_commas(&json_str);
124        let settings: Settings = serde_json::from_str(&clean)?;
125        Ok(settings)
126    }
127
128    fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
129        let home = dirs::home_dir().ok_or("HOME directory not found")?;
130        let dir = home.join(".config").join("seher");
131        let jsonc_path = dir.join("settings.jsonc");
132        if jsonc_path.exists() {
133            return Ok(jsonc_path);
134        }
135        Ok(dir.join("settings.json"))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn sample_settings_path() -> PathBuf {
144        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
145            .join("examples")
146            .join("settings.json")
147    }
148
149    #[test]
150    fn test_parse_sample_settings() {
151        let content = std::fs::read_to_string(sample_settings_path())
152            .expect("examples/settings.json not found");
153        let settings: Settings = serde_json::from_str(&content).expect("failed to parse settings");
154
155        assert_eq!(settings.agents.len(), 4);
156    }
157
158    #[test]
159    fn test_sample_settings_claude_agent() {
160        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
161        let settings: Settings = serde_json::from_str(&content).unwrap();
162
163        let claude = &settings.agents[0];
164        assert_eq!(claude.command, "claude");
165        assert_eq!(claude.args, ["--model", "{model}"]);
166
167        let models = claude.models.as_ref().expect("models should be present");
168        assert_eq!(models.get("high").map(String::as_str), Some("opus"));
169        assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
170        assert_eq!(
171            claude.arg_maps.get("--danger").cloned(),
172            Some(vec![
173                "--permission-mode".to_string(),
174                "bypassPermissions".to_string(),
175            ])
176        );
177
178        // no provider field → None (inferred from command name)
179        assert!(claude.provider.is_none());
180        assert_eq!(claude.resolve_domain(), Some("claude.ai"));
181    }
182
183    #[test]
184    fn test_sample_settings_copilot_agent() {
185        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
186        let settings: Settings = serde_json::from_str(&content).unwrap();
187
188        let opencode = &settings.agents[1];
189        assert_eq!(opencode.command, "opencode");
190        assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
191
192        let models = opencode.models.as_ref().expect("models should be present");
193        assert_eq!(
194            models.get("high").map(String::as_str),
195            Some("github-copilot/gpt-5.4")
196        );
197        assert_eq!(
198            models.get("low").map(String::as_str),
199            Some("github-copilot/claude-haiku-4.5")
200        );
201
202        // provider: "copilot" → Some(Some("copilot"))
203        assert_eq!(opencode.provider, Some(Some("copilot".to_string())));
204        assert_eq!(opencode.resolve_domain(), Some("github.com"));
205    }
206
207    #[test]
208    fn test_sample_settings_fallback_agent() {
209        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
210        let settings: Settings = serde_json::from_str(&content).unwrap();
211
212        let fallback = &settings.agents[3];
213        assert_eq!(fallback.command, "claude");
214
215        // provider: null → Some(None) (fallback)
216        assert_eq!(fallback.provider, Some(None));
217        assert_eq!(fallback.resolve_domain(), None);
218    }
219
220    #[test]
221    fn test_sample_settings_codex_agent() {
222        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
223        let settings: Settings = serde_json::from_str(&content).unwrap();
224
225        let codex = &settings.agents[2];
226        assert_eq!(codex.command, "codex");
227        assert!(codex.args.is_empty());
228        assert!(codex.models.is_none());
229        assert!(codex.provider.is_none());
230        assert_eq!(codex.resolve_domain(), Some("chatgpt.com"));
231    }
232
233    #[test]
234    fn test_provider_field_absent() {
235        let json = r#"{"agents": [{"command": "claude"}]}"#;
236        let settings: Settings = serde_json::from_str(json).unwrap();
237
238        assert!(settings.agents[0].provider.is_none());
239        assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
240    }
241
242    #[test]
243    fn test_provider_field_null() {
244        let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
245        let settings: Settings = serde_json::from_str(json).unwrap();
246
247        assert_eq!(settings.agents[0].provider, Some(None));
248        assert_eq!(settings.agents[0].resolve_domain(), None);
249    }
250
251    #[test]
252    fn test_provider_field_string() {
253        let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
254        let settings: Settings = serde_json::from_str(json).unwrap();
255
256        assert_eq!(
257            settings.agents[0].provider,
258            Some(Some("copilot".to_string()))
259        );
260        assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
261    }
262
263    #[test]
264    fn test_command_codex_resolves_chatgpt_domain() {
265        let json = r#"{"agents": [{"command": "codex"}]}"#;
266        let settings: Settings = serde_json::from_str(json).unwrap();
267
268        assert!(settings.agents[0].provider.is_none());
269        assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
270    }
271
272    #[test]
273    fn test_provider_field_codex_string() {
274        let json = r#"{"agents": [{"command": "opencode", "provider": "codex"}]}"#;
275        let settings: Settings = serde_json::from_str(json).unwrap();
276
277        assert_eq!(settings.agents[0].provider, Some(Some("codex".to_string())));
278        assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
279    }
280
281    #[test]
282    fn test_provider_unknown_string() {
283        let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
284        let settings: Settings = serde_json::from_str(json).unwrap();
285
286        assert_eq!(
287            settings.agents[0].provider,
288            Some(Some("unknown".to_string()))
289        );
290        assert_eq!(settings.agents[0].resolve_domain(), None);
291    }
292
293    #[test]
294    fn test_parse_minimal_settings_without_models() {
295        let json = r#"{"agents": [{"command": "claude"}]}"#;
296        let settings: Settings =
297            serde_json::from_str(json).expect("failed to parse minimal settings");
298
299        assert_eq!(settings.agents.len(), 1);
300        assert_eq!(settings.agents[0].command, "claude");
301        assert!(settings.agents[0].args.is_empty());
302        assert!(settings.agents[0].models.is_none());
303        assert!(settings.agents[0].arg_maps.is_empty());
304    }
305
306    #[test]
307    fn test_parse_settings_with_env() {
308        let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
309        let settings: Settings = serde_json::from_str(json).unwrap();
310
311        let env = settings.agents[0]
312            .env
313            .as_ref()
314            .expect("env should be present");
315        assert_eq!(
316            env.get("ANTHROPIC_API_KEY").map(String::as_str),
317            Some("sk-test")
318        );
319        assert_eq!(
320            env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
321            Some("100")
322        );
323    }
324
325    #[test]
326    fn test_parse_settings_with_args_no_models() {
327        let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
328        let settings: Settings = serde_json::from_str(json).unwrap();
329
330        assert_eq!(
331            settings.agents[0].args,
332            ["--permission-mode", "bypassPermissions"]
333        );
334        assert!(settings.agents[0].models.is_none());
335        assert!(settings.agents[0].arg_maps.is_empty());
336    }
337
338    #[test]
339    fn test_parse_jsonc_with_comments() {
340        let jsonc = r#"{
341            // This is a comment
342            "agents": [
343                {
344                    "command": "claude", /* inline comment */
345                    "args": ["--model", "{model}"]
346                }
347            ]
348        }"#;
349        let stripped = json_comments::StripComments::new(jsonc.as_bytes());
350        let settings: Settings = serde_json::from_reader(stripped).unwrap();
351        assert_eq!(settings.agents.len(), 1);
352        assert_eq!(settings.agents[0].command, "claude");
353    }
354
355    #[test]
356    fn test_parse_jsonc_with_trailing_commas() {
357        let jsonc = r#"{
358            // trailing commas
359            "agents": [
360                {
361                    "command": "claude",
362                    "args": ["--model", "{model}"],
363                },
364            ]
365        }"#;
366        let mut stripped = json_comments::StripComments::new(jsonc.as_bytes());
367        let mut json_str = String::new();
368        std::io::Read::read_to_string(&mut stripped, &mut json_str).unwrap();
369        let clean = strip_trailing_commas(&json_str);
370        let settings: Settings = serde_json::from_str(&clean).unwrap();
371        assert_eq!(settings.agents.len(), 1);
372        assert_eq!(settings.agents[0].command, "claude");
373    }
374
375    #[test]
376    fn test_parse_settings_with_arg_maps() {
377        let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
378        let settings: Settings = serde_json::from_str(json).unwrap();
379
380        assert_eq!(
381            settings.agents[0].arg_maps.get("--danger").cloned(),
382            Some(vec![
383                "--permission-mode".to_string(),
384                "bypassPermissions".to_string(),
385            ])
386        );
387    }
388}