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