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 } 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 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 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 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}