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
66impl Settings {
67    pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
68        let path = match path {
69            Some(p) => p.to_path_buf(),
70            None => Self::settings_path()?,
71        };
72        let content = match std::fs::read_to_string(&path) {
73            Ok(c) => c,
74            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
75                return Ok(Settings::default());
76            }
77            Err(e) => return Err(e.into()),
78        };
79        let stripped = json_comments::StripComments::new(content.as_bytes());
80        let settings: Settings = serde_json::from_reader(stripped)?;
81        Ok(settings)
82    }
83
84    fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
85        let home = dirs::home_dir().ok_or("HOME directory not found")?;
86        let dir = home.join(".seher");
87        let jsonc_path = dir.join("settings.jsonc");
88        if jsonc_path.exists() {
89            return Ok(jsonc_path);
90        }
91        Ok(dir.join("settings.json"))
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn sample_settings_path() -> PathBuf {
100        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
101            .join("examples")
102            .join("settings.json")
103    }
104
105    #[test]
106    fn test_parse_sample_settings() {
107        let content = std::fs::read_to_string(sample_settings_path())
108            .expect("examples/settings.json not found");
109        let settings: Settings = serde_json::from_str(&content).expect("failed to parse settings");
110
111        assert_eq!(settings.agents.len(), 3);
112    }
113
114    #[test]
115    fn test_sample_settings_claude_agent() {
116        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
117        let settings: Settings = serde_json::from_str(&content).unwrap();
118
119        let claude = &settings.agents[0];
120        assert_eq!(claude.command, "claude");
121        assert_eq!(claude.args, ["--model", "{model}"]);
122
123        let models = claude.models.as_ref().expect("models should be present");
124        assert_eq!(models.get("high").map(String::as_str), Some("opus"));
125        assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
126        assert_eq!(
127            claude.arg_maps.get("--danger").cloned(),
128            Some(vec![
129                "--permission-mode".to_string(),
130                "bypassPermissions".to_string(),
131            ])
132        );
133
134        // no provider field → None (inferred from command name)
135        assert!(claude.provider.is_none());
136        assert_eq!(claude.resolve_domain(), Some("claude.ai"));
137    }
138
139    #[test]
140    fn test_sample_settings_copilot_agent() {
141        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
142        let settings: Settings = serde_json::from_str(&content).unwrap();
143
144        let opencode = &settings.agents[1];
145        assert_eq!(opencode.command, "opencode");
146        assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
147
148        let models = opencode.models.as_ref().expect("models should be present");
149        assert_eq!(
150            models.get("high").map(String::as_str),
151            Some("github-copilot/gpt-5.4")
152        );
153        assert_eq!(
154            models.get("low").map(String::as_str),
155            Some("github-copilot/claude-haiku-4.5")
156        );
157
158        // provider: "copilot" → Some(Some("copilot"))
159        assert_eq!(opencode.provider, Some(Some("copilot".to_string())));
160        assert_eq!(opencode.resolve_domain(), Some("github.com"));
161    }
162
163    #[test]
164    fn test_sample_settings_fallback_agent() {
165        let content = std::fs::read_to_string(sample_settings_path()).unwrap();
166        let settings: Settings = serde_json::from_str(&content).unwrap();
167
168        let fallback = &settings.agents[2];
169        assert_eq!(fallback.command, "claude");
170
171        // provider: null → Some(None) (fallback)
172        assert_eq!(fallback.provider, Some(None));
173        assert_eq!(fallback.resolve_domain(), None);
174    }
175
176    #[test]
177    fn test_provider_field_absent() {
178        let json = r#"{"agents": [{"command": "claude"}]}"#;
179        let settings: Settings = serde_json::from_str(json).unwrap();
180
181        assert!(settings.agents[0].provider.is_none());
182        assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
183    }
184
185    #[test]
186    fn test_provider_field_null() {
187        let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
188        let settings: Settings = serde_json::from_str(json).unwrap();
189
190        assert_eq!(settings.agents[0].provider, Some(None));
191        assert_eq!(settings.agents[0].resolve_domain(), None);
192    }
193
194    #[test]
195    fn test_provider_field_string() {
196        let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
197        let settings: Settings = serde_json::from_str(json).unwrap();
198
199        assert_eq!(
200            settings.agents[0].provider,
201            Some(Some("copilot".to_string()))
202        );
203        assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
204    }
205
206    #[test]
207    fn test_provider_unknown_string() {
208        let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
209        let settings: Settings = serde_json::from_str(json).unwrap();
210
211        assert_eq!(
212            settings.agents[0].provider,
213            Some(Some("unknown".to_string()))
214        );
215        assert_eq!(settings.agents[0].resolve_domain(), None);
216    }
217
218    #[test]
219    fn test_parse_minimal_settings_without_models() {
220        let json = r#"{"agents": [{"command": "claude"}]}"#;
221        let settings: Settings =
222            serde_json::from_str(json).expect("failed to parse minimal settings");
223
224        assert_eq!(settings.agents.len(), 1);
225        assert_eq!(settings.agents[0].command, "claude");
226        assert!(settings.agents[0].args.is_empty());
227        assert!(settings.agents[0].models.is_none());
228        assert!(settings.agents[0].arg_maps.is_empty());
229    }
230
231    #[test]
232    fn test_parse_settings_with_env() {
233        let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
234        let settings: Settings = serde_json::from_str(json).unwrap();
235
236        let env = settings.agents[0]
237            .env
238            .as_ref()
239            .expect("env should be present");
240        assert_eq!(
241            env.get("ANTHROPIC_API_KEY").map(String::as_str),
242            Some("sk-test")
243        );
244        assert_eq!(
245            env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
246            Some("100")
247        );
248    }
249
250    #[test]
251    fn test_parse_settings_with_args_no_models() {
252        let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
253        let settings: Settings = serde_json::from_str(json).unwrap();
254
255        assert_eq!(
256            settings.agents[0].args,
257            ["--permission-mode", "bypassPermissions"]
258        );
259        assert!(settings.agents[0].models.is_none());
260        assert!(settings.agents[0].arg_maps.is_empty());
261    }
262
263    #[test]
264    fn test_parse_jsonc_with_comments() {
265        let jsonc = r#"{
266            // This is a comment
267            "agents": [
268                {
269                    "command": "claude", /* inline comment */
270                    "args": ["--model", "{model}"]
271                }
272            ]
273        }"#;
274        let stripped = json_comments::StripComments::new(jsonc.as_bytes());
275        let settings: Settings = serde_json::from_reader(stripped).unwrap();
276        assert_eq!(settings.agents.len(), 1);
277        assert_eq!(settings.agents[0].command, "claude");
278    }
279
280    #[test]
281    fn test_parse_settings_with_arg_maps() {
282        let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
283        let settings: Settings = serde_json::from_str(json).unwrap();
284
285        assert_eq!(
286            settings.agents[0].arg_maps.get("--danger").cloned(),
287            Some(vec![
288                "--permission-mode".to_string(),
289                "bypassPermissions".to_string(),
290            ])
291        );
292    }
293}