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