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 crate::agent::env_test_support::{env_lock, ScopedEnv};
173 use std::fs;
174 use std::path::Path;
175
176 fn write(path: &Path, body: &str) {
177 if let Some(p) = path.parent() {
178 fs::create_dir_all(p).expect("mkdir -p");
179 }
180 fs::write(path, body).expect("write");
181 }
182
183 #[test]
184 fn default_is_all_none() {
185 let s = AprSettings::default();
186 assert!(s.model.is_none());
187 assert!(s.max_turns.is_none());
188 assert!(s.extra_system_prompt.is_none());
189 assert!(s.project.is_none());
190 }
191
192 #[test]
193 fn from_json_parses_minimal() {
194 let s = AprSettings::from_json_str(r#"{"model":"qwen3:1.7b-q4k"}"#).expect("parse");
195 assert_eq!(s.model.as_deref(), Some("qwen3:1.7b-q4k"));
196 assert!(s.max_turns.is_none());
197 }
198
199 #[test]
200 fn from_json_parses_full() {
201 let s = AprSettings::from_json_str(
202 r#"{"model":"qwen3:1.7b-q4k","max_turns":25,"extra_system_prompt":"Be terse","project":"/tmp/proj"}"#,
203 )
204 .expect("parse");
205 assert_eq!(s.model.as_deref(), Some("qwen3:1.7b-q4k"));
206 assert_eq!(s.max_turns, Some(25));
207 assert_eq!(s.extra_system_prompt.as_deref(), Some("Be terse"));
208 assert_eq!(s.project.as_deref(), Some(Path::new("/tmp/proj")));
209 }
210
211 #[test]
212 fn from_json_empty_is_default() {
213 let s = AprSettings::from_json_str("").expect("empty");
214 assert_eq!(s, AprSettings::default());
215 let s = AprSettings::from_json_str(" \n\t ").expect("whitespace");
216 assert_eq!(s, AprSettings::default());
217 }
218
219 #[test]
220 fn from_json_malformed_errs_loudly() {
221 let err = AprSettings::from_json_str("{not json").expect_err("must err");
222 assert!(format!("{err}").contains("invalid settings JSON"));
223 }
224
225 #[test]
226 fn from_json_unknown_field_is_rejected() {
227 let err = AprSettings::from_json_str(r#"{"modle":"foo"}"#).expect_err("must reject typo");
229 assert!(format!("{err}").contains("invalid settings JSON"));
230 }
231
232 #[test]
235 fn from_json_parses_permission_mode_camel() {
236 let s = AprSettings::from_json_str(r#"{"permissionMode":"acceptEdits"}"#).expect("parse");
238 assert_eq!(s.permission_mode.as_deref(), Some("acceptEdits"));
239 }
240
241 #[test]
242 fn from_json_parses_permission_mode_snake_alias() {
243 let s = AprSettings::from_json_str(r#"{"permission_mode":"plan"}"#).expect("parse");
245 assert_eq!(s.permission_mode.as_deref(), Some("plan"));
246 }
247
248 #[test]
249 fn from_json_parses_allowed_hosts_camel() {
250 let s =
251 AprSettings::from_json_str(r#"{"allowedHosts":["docs.anthropic.com","crates.io"]}"#)
252 .expect("parse");
253 assert_eq!(
254 s.allowed_hosts.as_deref(),
255 Some(&["docs.anthropic.com".to_string(), "crates.io".to_string()][..])
256 );
257 }
258
259 #[test]
260 fn from_json_parses_allowed_hosts_snake_alias() {
261 let s = AprSettings::from_json_str(r#"{"allowed_hosts":["github.com"]}"#).expect("parse");
262 assert_eq!(s.allowed_hosts.as_deref(), Some(&["github.com".to_string()][..]));
263 }
264
265 #[test]
266 fn merge_permission_mode_other_wins() {
267 let mut base =
268 AprSettings { permission_mode: Some("default".into()), ..Default::default() };
269 let over = AprSettings { permission_mode: Some("plan".into()), ..Default::default() };
270 base.merge(&over);
271 assert_eq!(base.permission_mode.as_deref(), Some("plan"));
272 }
273
274 #[test]
275 fn merge_allowed_hosts_other_wins_replaces_not_unions() {
276 let mut base = AprSettings {
280 allowed_hosts: Some(vec!["a.com".into(), "b.com".into()]),
281 ..Default::default()
282 };
283 let over = AprSettings { allowed_hosts: Some(vec!["c.com".into()]), ..Default::default() };
284 base.merge(&over);
285 assert_eq!(base.allowed_hosts.as_deref(), Some(&["c.com".to_string()][..]));
286 }
287
288 #[test]
289 fn merge_other_wins() {
290 let mut base =
291 AprSettings { model: Some("a".into()), max_turns: Some(10), ..Default::default() };
292 let over = AprSettings { model: Some("b".into()), ..Default::default() };
293 base.merge(&over);
294 assert_eq!(base.model.as_deref(), Some("b"));
295 assert_eq!(base.max_turns, Some(10), "untouched fields keep base value");
296 }
297
298 #[test]
299 fn merge_none_keeps_base() {
300 let mut base = AprSettings { model: Some("a".into()), ..Default::default() };
301 let over = AprSettings::default();
302 base.merge(&over);
303 assert_eq!(base.model.as_deref(), Some("a"));
304 }
305
306 #[test]
307 fn read_missing_path_returns_default() {
308 let p = std::env::temp_dir().join("does-not-exist-aprcfg.json");
309 let _ = std::fs::remove_file(&p);
310 let s = AprSettings::read_from_path(&p).expect("missing is ok");
311 assert_eq!(s, AprSettings::default());
312 }
313
314 #[test]
315 fn read_malformed_path_errs_loudly() {
316 let dir = tempfile::tempdir().expect("tempdir");
317 let p = dir.path().join("settings.json");
318 write(&p, "{not json");
319 let err = AprSettings::read_from_path(&p).expect_err("must err");
320 let msg = format!("{err}");
321 assert!(msg.contains("invalid settings JSON") || msg.contains("settings.json"));
322 }
323
324 #[test]
325 fn user_global_honors_apr_config_env() {
326 let _guard = env_lock();
327 let dir = tempfile::tempdir().expect("tempdir");
328 let _env = ScopedEnv::set("APR_CONFIG", dir.path());
329 let p = AprSettings::user_global_path().expect("path resolved");
330 assert_eq!(p, dir.path().join("settings.json"));
331 }
332
333 #[test]
334 fn project_local_path_under_project() {
335 let p = AprSettings::project_local_path(Path::new("/tmp/myproj"));
336 assert_eq!(p, Path::new("/tmp/myproj/.apr/settings.json"));
337 }
338
339 #[test]
340 fn load_layered_project_overrides_user_global() {
341 let _guard = env_lock();
342 let cfg_dir = tempfile::tempdir().expect("cfg tempdir");
345 let proj_dir = tempfile::tempdir().expect("proj tempdir");
346 write(&cfg_dir.path().join("settings.json"), r#"{"model":"user-global","max_turns":5}"#);
347 write(&proj_dir.path().join(".apr").join("settings.json"), r#"{"model":"project-local"}"#);
348 let _env = ScopedEnv::set("APR_CONFIG", cfg_dir.path());
349 let s = AprSettings::load_layered(proj_dir.path()).expect("load");
350
351 assert_eq!(s.model.as_deref(), Some("project-local"), "project must win");
352 assert_eq!(s.max_turns, Some(5), "user-global field passes through when project is silent");
353 }
354
355 #[test]
356 fn load_layered_no_files_returns_default() {
357 let _guard = env_lock();
358 let cfg_dir = tempfile::tempdir().expect("cfg tempdir");
359 let proj_dir = tempfile::tempdir().expect("proj tempdir");
360 let _env = ScopedEnv::set("APR_CONFIG", cfg_dir.path());
362 let s = AprSettings::load_layered(proj_dir.path()).expect("load");
363 assert_eq!(s, AprSettings::default());
364 }
365}