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