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    // PMAT-876: shared crate-wide env lock + save/restore guard. All
169    // env-mutating tests across auto_memory + settings + instructions
170    // serialize on this ONE lock because they touch the same global
171    // `APR_CONFIG` variable.
172    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        // Poka-Yoke: typo in field name shouldn't silently no-op.
228        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    // PMAT-CODE-CONFIG-LADDER-FIELDS-001 — permission_mode + allowed_hosts
233
234    #[test]
235    fn from_json_parses_permission_mode_camel() {
236        // Claude Code's `permissionMode` shape (camelCase wire form).
237        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        // Operator-friendly snake_case alias also accepted.
244        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        // Settings ladder semantics: project-local fully replaces user-global
277        // for any field with `Some(_)`. We do NOT do list-union — operator
278        // who wants both must list both in the project file.
279        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        // Set up a temp APR_CONFIG with model="user" and a temp project with model="project".
343        // Project must win.
344        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        // No settings.json written anywhere.
361        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}