1use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct AprSettings {
46 pub model: Option<String>,
49
50 pub max_turns: Option<u32>,
53
54 pub extra_system_prompt: Option<String>,
57
58 pub project: Option<PathBuf>,
61
62 #[serde(rename = "permissionMode", alias = "permission_mode")]
75 pub permission_mode: Option<String>,
76
77 #[serde(rename = "allowedHosts", alias = "allowed_hosts")]
83 pub allowed_hosts: Option<Vec<String>>,
84}
85
86impl AprSettings {
87 pub fn merge(&mut self, other: &AprSettings) {
90 if other.model.is_some() {
91 self.model = other.model.clone();
92 }
93 if other.max_turns.is_some() {
94 self.max_turns = other.max_turns;
95 }
96 if other.extra_system_prompt.is_some() {
97 self.extra_system_prompt = other.extra_system_prompt.clone();
98 }
99 if other.project.is_some() {
100 self.project = other.project.clone();
101 }
102 if other.permission_mode.is_some() {
103 self.permission_mode = other.permission_mode.clone();
104 }
105 if other.allowed_hosts.is_some() {
106 self.allowed_hosts = other.allowed_hosts.clone();
107 }
108 }
109
110 pub fn from_json_str(buf: &str) -> anyhow::Result<Self> {
115 let trimmed = buf.trim();
116 if trimmed.is_empty() {
117 return Ok(Self::default());
118 }
119 serde_json::from_str::<Self>(trimmed)
120 .map_err(|e| anyhow::anyhow!("invalid settings JSON: {e}"))
121 }
122
123 pub fn read_from_path(path: &Path) -> anyhow::Result<Self> {
126 if !path.exists() {
127 return Ok(Self::default());
128 }
129 let buf = std::fs::read_to_string(path)
130 .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
131 Self::from_json_str(&buf).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
132 }
133
134 pub fn user_global_path() -> Option<PathBuf> {
137 if let Ok(custom) = std::env::var("APR_CONFIG") {
138 if !custom.is_empty() {
139 return Some(PathBuf::from(custom).join("settings.json"));
140 }
141 }
142 dirs::config_dir().map(|d| d.join("apr").join("settings.json"))
144 }
145
146 pub fn project_local_path(project_root: &Path) -> PathBuf {
148 project_root.join(".apr").join("settings.json")
149 }
150
151 pub fn load_layered(project_root: &Path) -> anyhow::Result<Self> {
156 let mut merged = Self::default();
157 if let Some(p) = Self::user_global_path() {
158 merged.merge(&Self::read_from_path(&p)?);
159 }
160 merged.merge(&Self::read_from_path(&Self::project_local_path(project_root))?);
161 Ok(merged)
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::fs;
169 use std::path::Path;
170
171 fn write(path: &Path, body: &str) {
172 if let Some(p) = path.parent() {
173 fs::create_dir_all(p).expect("mkdir -p");
174 }
175 fs::write(path, body).expect("write");
176 }
177
178 #[test]
179 fn default_is_all_none() {
180 let s = AprSettings::default();
181 assert!(s.model.is_none());
182 assert!(s.max_turns.is_none());
183 assert!(s.extra_system_prompt.is_none());
184 assert!(s.project.is_none());
185 }
186
187 #[test]
188 fn from_json_parses_minimal() {
189 let s = AprSettings::from_json_str(r#"{"model":"qwen3:1.7b-q4k"}"#).expect("parse");
190 assert_eq!(s.model.as_deref(), Some("qwen3:1.7b-q4k"));
191 assert!(s.max_turns.is_none());
192 }
193
194 #[test]
195 fn from_json_parses_full() {
196 let s = AprSettings::from_json_str(
197 r#"{"model":"qwen3:1.7b-q4k","max_turns":25,"extra_system_prompt":"Be terse","project":"/tmp/proj"}"#,
198 )
199 .expect("parse");
200 assert_eq!(s.model.as_deref(), Some("qwen3:1.7b-q4k"));
201 assert_eq!(s.max_turns, Some(25));
202 assert_eq!(s.extra_system_prompt.as_deref(), Some("Be terse"));
203 assert_eq!(s.project.as_deref(), Some(Path::new("/tmp/proj")));
204 }
205
206 #[test]
207 fn from_json_empty_is_default() {
208 let s = AprSettings::from_json_str("").expect("empty");
209 assert_eq!(s, AprSettings::default());
210 let s = AprSettings::from_json_str(" \n\t ").expect("whitespace");
211 assert_eq!(s, AprSettings::default());
212 }
213
214 #[test]
215 fn from_json_malformed_errs_loudly() {
216 let err = AprSettings::from_json_str("{not json").expect_err("must err");
217 assert!(format!("{err}").contains("invalid settings JSON"));
218 }
219
220 #[test]
221 fn from_json_unknown_field_is_rejected() {
222 let err = AprSettings::from_json_str(r#"{"modle":"foo"}"#).expect_err("must reject typo");
224 assert!(format!("{err}").contains("invalid settings JSON"));
225 }
226
227 #[test]
230 fn from_json_parses_permission_mode_camel() {
231 let s = AprSettings::from_json_str(r#"{"permissionMode":"acceptEdits"}"#).expect("parse");
233 assert_eq!(s.permission_mode.as_deref(), Some("acceptEdits"));
234 }
235
236 #[test]
237 fn from_json_parses_permission_mode_snake_alias() {
238 let s = AprSettings::from_json_str(r#"{"permission_mode":"plan"}"#).expect("parse");
240 assert_eq!(s.permission_mode.as_deref(), Some("plan"));
241 }
242
243 #[test]
244 fn from_json_parses_allowed_hosts_camel() {
245 let s =
246 AprSettings::from_json_str(r#"{"allowedHosts":["docs.anthropic.com","crates.io"]}"#)
247 .expect("parse");
248 assert_eq!(
249 s.allowed_hosts.as_deref(),
250 Some(&["docs.anthropic.com".to_string(), "crates.io".to_string()][..])
251 );
252 }
253
254 #[test]
255 fn from_json_parses_allowed_hosts_snake_alias() {
256 let s = AprSettings::from_json_str(r#"{"allowed_hosts":["github.com"]}"#).expect("parse");
257 assert_eq!(s.allowed_hosts.as_deref(), Some(&["github.com".to_string()][..]));
258 }
259
260 #[test]
261 fn merge_permission_mode_other_wins() {
262 let mut base =
263 AprSettings { permission_mode: Some("default".into()), ..Default::default() };
264 let over = AprSettings { permission_mode: Some("plan".into()), ..Default::default() };
265 base.merge(&over);
266 assert_eq!(base.permission_mode.as_deref(), Some("plan"));
267 }
268
269 #[test]
270 fn merge_allowed_hosts_other_wins_replaces_not_unions() {
271 let mut base = AprSettings {
275 allowed_hosts: Some(vec!["a.com".into(), "b.com".into()]),
276 ..Default::default()
277 };
278 let over = AprSettings { allowed_hosts: Some(vec!["c.com".into()]), ..Default::default() };
279 base.merge(&over);
280 assert_eq!(base.allowed_hosts.as_deref(), Some(&["c.com".to_string()][..]));
281 }
282
283 #[test]
284 fn merge_other_wins() {
285 let mut base =
286 AprSettings { model: Some("a".into()), max_turns: Some(10), ..Default::default() };
287 let over = AprSettings { model: Some("b".into()), ..Default::default() };
288 base.merge(&over);
289 assert_eq!(base.model.as_deref(), Some("b"));
290 assert_eq!(base.max_turns, Some(10), "untouched fields keep base value");
291 }
292
293 #[test]
294 fn merge_none_keeps_base() {
295 let mut base = AprSettings { model: Some("a".into()), ..Default::default() };
296 let over = AprSettings::default();
297 base.merge(&over);
298 assert_eq!(base.model.as_deref(), Some("a"));
299 }
300
301 #[test]
302 fn read_missing_path_returns_default() {
303 let p = std::env::temp_dir().join("does-not-exist-aprcfg.json");
304 let _ = std::fs::remove_file(&p);
305 let s = AprSettings::read_from_path(&p).expect("missing is ok");
306 assert_eq!(s, AprSettings::default());
307 }
308
309 #[test]
310 fn read_malformed_path_errs_loudly() {
311 let dir = tempfile::tempdir().expect("tempdir");
312 let p = dir.path().join("settings.json");
313 write(&p, "{not json");
314 let err = AprSettings::read_from_path(&p).expect_err("must err");
315 let msg = format!("{err}");
316 assert!(msg.contains("invalid settings JSON") || msg.contains("settings.json"));
317 }
318
319 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
325 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
326 LOCK.lock().unwrap_or_else(|e| e.into_inner())
327 }
328
329 #[test]
330 fn user_global_honors_apr_config_env() {
331 let _guard = env_lock();
332 let dir = tempfile::tempdir().expect("tempdir");
333 std::env::set_var("APR_CONFIG", dir.path());
334 let p = AprSettings::user_global_path().expect("path resolved");
335 assert_eq!(p, dir.path().join("settings.json"));
336 std::env::remove_var("APR_CONFIG");
337 }
338
339 #[test]
340 fn project_local_path_under_project() {
341 let p = AprSettings::project_local_path(Path::new("/tmp/myproj"));
342 assert_eq!(p, Path::new("/tmp/myproj/.apr/settings.json"));
343 }
344
345 #[test]
346 fn load_layered_project_overrides_user_global() {
347 let _guard = env_lock();
348 let cfg_dir = tempfile::tempdir().expect("cfg tempdir");
351 let proj_dir = tempfile::tempdir().expect("proj tempdir");
352 write(&cfg_dir.path().join("settings.json"), r#"{"model":"user-global","max_turns":5}"#);
353 write(&proj_dir.path().join(".apr").join("settings.json"), r#"{"model":"project-local"}"#);
354 std::env::set_var("APR_CONFIG", cfg_dir.path());
355 let s = AprSettings::load_layered(proj_dir.path()).expect("load");
356 std::env::remove_var("APR_CONFIG");
357
358 assert_eq!(s.model.as_deref(), Some("project-local"), "project must win");
359 assert_eq!(s.max_turns, Some(5), "user-global field passes through when project is silent");
360 }
361
362 #[test]
363 fn load_layered_no_files_returns_default() {
364 let _guard = env_lock();
365 let cfg_dir = tempfile::tempdir().expect("cfg tempdir");
366 let proj_dir = tempfile::tempdir().expect("proj tempdir");
367 std::env::set_var("APR_CONFIG", cfg_dir.path());
369 let s = AprSettings::load_layered(proj_dir.path()).expect("load");
370 std::env::remove_var("APR_CONFIG");
371 assert_eq!(s, AprSettings::default());
372 }
373}