Skip to main content

batuta/agent/
settings.rs

1//! Claude-Code-parity settings ladder for `apr code`
2//! (PMAT-CODE-CONFIG-LADDER-001).
3//!
4//! Implements the same user-global → project-local → CLI-override
5//! precedence ladder Claude Code uses for `~/.claude/settings.json`,
6//! adapted to the apr-code surface:
7//!
8//! | Layer | Path | Notes |
9//! |-------|------|-------|
10//! | User-global | `$APR_CONFIG/settings.json` (override) or `~/.config/apr/settings.json` | machine-wide defaults |
11//! | Project-local | `<project_root>/.apr/settings.json` | repo-specific overrides |
12//! | CLI flags | `--model`, `--max-turns`, `--manifest` | always wins |
13//!
14//! ## Precedence
15//!
16//! Latter layers override earlier ones field-by-field. Missing files at any
17//! layer are non-errors (the layer just contributes no fields). Malformed
18//! JSON is a hard error so the operator notices the broken file rather than
19//! silently running on partial config (Poka-Yoke).
20//!
21//! ## Why JSON, not TOML
22//!
23//! `apr code`'s legacy `--manifest` flag accepts TOML
24//! ([`AgentManifest`](super::manifest::AgentManifest)). The settings ladder
25//! is *additive*: `settings.json` carries the small set of fields a typical
26//! user wants to set machine-wide (model, max_turns, system prompt extras),
27//! and matches Claude Code's `settings.json` format so users coming from
28//! Claude Code can copy their settings file with minimal edits.
29//!
30//! For full agent specification (capabilities, hooks, MCP servers,
31//! resource quotas), use `--manifest path/to/manifest.toml` — it
32//! short-circuits the ladder.
33
34use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36
37/// Claude-Code-parity settings layer (`apr code`).
38///
39/// Every field is `Option<_>` so we can tell "explicitly set" apart from
40/// "use the next layer's default" during merge. After merge, unset fields
41/// fall back to [`super::manifest::ModelConfig::default()`] /
42/// build_default_manifest values.
43#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct AprSettings {
46    /// Path to local model file OR HuggingFace repo (e.g. `qwen3:1.7b-q4k`).
47    /// Mirrors Claude Code's `model: "claude-3-5-sonnet-20241022"`.
48    pub model: Option<String>,
49
50    /// Maximum REPL/agent turns before stopping. Claude Code uses no
51    /// equivalent (it caps via budget); this is an apr-code-specific knob.
52    pub max_turns: Option<u32>,
53
54    /// Extra text appended to the agent's system prompt. Mirrors
55    /// Claude Code's `customApiKeyResponses` / `extraSystemPrompt`.
56    pub extra_system_prompt: Option<String>,
57
58    /// Default working-directory project root override. Resolved relative
59    /// to the settings file's directory; absolute paths pass through.
60    pub project: Option<PathBuf>,
61
62    /// Permission mode for the agent's tool dispatch (PMAT-CODE-CONFIG-LADDER-FIELDS-001).
63    /// Mirrors Claude Code's `permissionMode` field. Accepts the camelCase /
64    /// kebab-case / snake_case aliases that
65    /// [`crate::agent::permission::PermissionMode::parse`] honors:
66    /// `"default" | "plan" | "acceptEdits" | "bypassPermissions"` (and
67    /// case-insensitive equivalents). Unknown strings produce a settings-load
68    /// error from `apply_settings_to_manifest` (Poka-Yoke).
69    ///
70    /// Stored as `Option<String>` rather than `Option<PermissionMode>` so the
71    /// settings type stays JSON-trivial and so unknown values surface a
72    /// clear `apr code` error message at apply time rather than a generic
73    /// serde error at parse time.
74    #[serde(rename = "permissionMode", alias = "permission_mode")]
75    pub permission_mode: Option<String>,
76
77    /// Hostnames the agent's `NetworkTool` / `BrowserTool` may reach
78    /// (PMAT-CODE-CONFIG-LADDER-FIELDS-001). Mirrors `AgentManifest.allowed_hosts`.
79    /// Sovereign privacy tier always blocks network tools regardless of this
80    /// list (Poka-Yoke; tier wins over config). Empty list = no network
81    /// tools registered.
82    #[serde(rename = "allowedHosts", alias = "allowed_hosts")]
83    pub allowed_hosts: Option<Vec<String>>,
84}
85
86impl AprSettings {
87    /// Field-by-field merge: `other` wins over `self` for any `Some(_)` field.
88    /// Used to fold project-local over user-global, then CLI over that.
89    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    /// Parse JSON text into a settings layer. Empty/whitespace-only text
111    /// is treated as "no settings here" (returns Default), matching the
112    /// missing-file convention. Malformed JSON returns a hard error so
113    /// the operator notices instead of silently running on partial config.
114    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    /// Read a settings file. Missing files return `Default` (non-error).
124    /// Malformed JSON or non-readable files are hard errors.
125    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    /// User-global settings path: `$APR_CONFIG/settings.json` if set,
135    /// else `~/.config/apr/settings.json` (XDG-style).
136    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() returns ~/.config on Linux, equivalent on macOS/Windows.
143        dirs::config_dir().map(|d| d.join("apr").join("settings.json"))
144    }
145
146    /// Project-local settings path: `<project_root>/.apr/settings.json`.
147    pub fn project_local_path(project_root: &Path) -> PathBuf {
148        project_root.join(".apr").join("settings.json")
149    }
150
151    /// Load and merge the user-global → project-local layers.
152    /// CLI overrides happen at the call site (after this returns).
153    ///
154    /// Field precedence: project-local > user-global > defaults.
155    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        // Poka-Yoke: typo in field name shouldn't silently no-op.
223        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    // PMAT-CODE-CONFIG-LADDER-FIELDS-001 — permission_mode + allowed_hosts
228
229    #[test]
230    fn from_json_parses_permission_mode_camel() {
231        // Claude Code's `permissionMode` shape (camelCase wire form).
232        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        // Operator-friendly snake_case alias also accepted.
239        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        // Settings ladder semantics: project-local fully replaces user-global
272        // for any field with `Some(_)`. We do NOT do list-union — operator
273        // who wants both must list both in the project file.
274        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    // CI flake prevention: tests below mutate the process-wide
320    // `APR_CONFIG` env var. cargo test runs `#[test]` fns in parallel
321    // by default; without serialization, two parallel tests can corrupt
322    // each other's view of the env (test A sets, test B reads, test A
323    // removes). Same fix as agent::instructions::tests (PR #1567).
324    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        // Set up a temp APR_CONFIG with model="user" and a temp project with model="project".
349        // Project must win.
350        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        // No settings.json written anywhere.
368        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}