Skip to main content

agentd/config/
file.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The declarative config **file** (RFC 0017 §3) + its JSON Schema (§4.2).
3//!
4//! One document, two syntaxes: **YAML** (`.yaml`/`.yml`, read by the
5//! hand-rolled [`super::yaml`] subset reader — no `serde_yaml`, the minimalism
6//! moat) or **JSON** with comments (`.json`/`.jsonc`); an unknown extension is
7//! sniffed (`{`/`[` ⇒ JSON, else YAML). Both parse to the same
8//! `serde_json::Value` document ([`read_document`]) and then to the typed
9//! [`ConfigFile`] ([`ConfigFile::from_document`]) — so validation, the schema,
10//! the env/flag path bindings ([`super::paths`]) and hot reload are all
11//! format-agnostic.
12//!
13//! The file carries **only verbose structural config**: the MCP-server
14//! inventory, declared subscriptions, A2A peers, limits, and the model/log
15//! knobs. It **never** carries secrets or per-environment scalars (those stay
16//! env/flag).
17//!
18//! Precedence (RFC 0011 §2.1 / RFC 0017 §3.2): `built-in default < FILE < env <
19//! flag`. The file is loaded first, then `Config::load` applies env
20//! and flags over it; a flag/env for the same key wins. List-valued keys
21//! (`mcp_servers`, `subscribe`, `a2a_peers`) *seed* the list — repeatable
22//! `--mcp`/`--subscribe`/`--a2a-peer` flags **add to** the file's list (the
23//! repeatable-flag semantics operators already expect, §3.2).
24//!
25//! `deny_unknown_fields` makes a typo'd key (`max_token` vs `max_tokens`) a hard
26//! config error (exit 2) instead of a silently-ignored value — the single most
27//! common config footgun, closed at parse time.
28//!
29//! The schema is **hand-written** (no `schemars` — a forbidden dependency) and
30//! kept faithful to this struct by a unit test asserting the schema's top-level
31//! properties match the struct's fields (so they can't silently drift, §4.2).
32
33use serde::Deserialize;
34use serde_json::{Value, json};
35use std::collections::BTreeMap;
36use std::path::Path;
37
38/// The two config-file syntaxes.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Format {
41    /// JSON, with `//` and `/* */` comments tolerated (jsonc).
42    Json,
43    /// The YAML subset [`super::yaml`] reads.
44    Yaml,
45}
46
47impl Format {
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Format::Json => "json",
51            Format::Yaml => "yaml",
52        }
53    }
54
55    /// Decide the format of a config document: the file extension when it is a
56    /// known one (`.yaml`/`.yml` ⇒ YAML; `.json`/`.jsonc` ⇒ JSON), else by
57    /// sniffing the text — a document whose first significant character (after
58    /// whitespace and `//`/`/* */` comments) is `{` or `[` is JSON, anything
59    /// else is YAML.
60    pub fn detect(path: Option<&Path>, text: &str) -> Format {
61        if let Some(ext) = path.and_then(|p| p.extension()).and_then(|e| e.to_str()) {
62            match ext.to_ascii_lowercase().as_str() {
63                "yaml" | "yml" => return Format::Yaml,
64                "json" | "jsonc" => return Format::Json,
65                _ => {}
66            }
67        }
68        Format::sniff(text)
69    }
70
71    fn sniff(text: &str) -> Format {
72        let t = text.strip_prefix('\u{feff}').unwrap_or(text);
73        let bytes = t.as_bytes();
74        let mut i = 0;
75        loop {
76            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
77                i += 1;
78            }
79            if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
80                while i < bytes.len() && bytes[i] != b'\n' {
81                    i += 1;
82                }
83                continue;
84            }
85            if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
86                i += 2;
87                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
88                    i += 1;
89                }
90                i += 2;
91                continue;
92            }
93            break;
94        }
95        match bytes.get(i) {
96            Some(b'{') | Some(b'[') => Format::Json,
97            _ => Format::Yaml,
98        }
99    }
100}
101
102/// Parse config text of the given format into its document (a JSON value). A
103/// syntax error names the line/column; the document must be a mapping (object)
104/// at the top level.
105pub fn parse_document(text: &str, format: Format) -> Result<Value, String> {
106    let doc = match format {
107        Format::Json => {
108            let stripped = strip_jsonc(text);
109            serde_json::from_str::<Value>(&stripped)
110                .map_err(|e| format!("config file parse error (json): {e}"))?
111        }
112        Format::Yaml => {
113            super::yaml::parse(text).map_err(|e| format!("config file parse error (yaml): {e}"))?
114        }
115    };
116    match doc {
117        Value::Object(_) => Ok(doc),
118        Value::Null if format == Format::Yaml => Ok(Value::Object(serde_json::Map::new())),
119        other => Err(format!(
120            "config file must be a mapping (an object) at the top level, got {}",
121            kind_name(&other)
122        )),
123    }
124}
125
126/// Read + parse a config file from a local path into its document, deciding the
127/// format from the extension (else by sniffing the text). Errors name the path.
128pub fn read_document(path: &str) -> Result<(Value, Format), String> {
129    let text = std::fs::read_to_string(path)
130        .map_err(|e| format!("cannot read config file {path}: {e}"))?;
131    let format = Format::detect(Some(Path::new(path)), &text);
132    let doc = parse_document(&text, format).map_err(|e| format!("{path}: {e}"))?;
133    Ok((doc, format))
134}
135
136/// Read several config files, in order, into ONE effective document: each later
137/// file is merged over the previous ones with **JSON Merge Patch** semantics
138/// (RFC 7396) — objects merge recursively, scalars and lists are REPLACED by the
139/// later file, and a `null` value UNSETS the key. Every file is type-checked on
140/// its own first (so an unknown key is reported against the file that carries
141/// it), then the merged document is returned with the `(path, format)` list.
142pub fn read_documents(paths: &[String]) -> Result<(Value, Vec<(String, Format)>), String> {
143    read_documents_checked(paths, &|doc, source| {
144        ConfigFile::from_document(doc.clone(), source).map(|_| ())
145    })
146}
147
148/// [`read_documents`] with a caller-supplied per-file check (the v2 settings
149/// typing, or none) — `check(doc, "config file <path>")` runs before the merge
150/// so an unknown key is attributed to its file.
151pub fn read_documents_checked(
152    paths: &[String],
153    check: &dyn Fn(&Value, &str) -> Result<(), String>,
154) -> Result<(Value, Vec<(String, Format)>), String> {
155    let mut merged = Value::Object(serde_json::Map::new());
156    let mut loaded = Vec::with_capacity(paths.len());
157    for path in paths {
158        let (doc, format) = read_document(path)?;
159        check(&doc, &format!("config file {path}"))?;
160        merge_into(&mut merged, doc);
161        loaded.push((path.clone(), format));
162    }
163    Ok((merged, loaded))
164}
165
166/// JSON Merge Patch (RFC 7396): `overlay` onto `base`. Objects merge key by key
167/// (recursively); any other value — a scalar or a list — replaces what was
168/// there; an explicit `null` removes the key. A non-object overlay replaces the
169/// base wholesale.
170pub fn merge_into(base: &mut Value, overlay: Value) {
171    match overlay {
172        Value::Object(over) => {
173            if !base.is_object() {
174                *base = Value::Object(serde_json::Map::new());
175            }
176            let map = base.as_object_mut().expect("just ensured an object");
177            for (k, v) in over {
178                match v {
179                    Value::Null => {
180                        map.remove(&k);
181                    }
182                    Value::Object(_) => {
183                        let slot = map
184                            .entry(k)
185                            .or_insert(Value::Object(serde_json::Map::new()));
186                        merge_into(slot, v);
187                    }
188                    other => {
189                        map.insert(k, other);
190                    }
191                }
192            }
193        }
194        other => *base = other,
195    }
196}
197
198fn kind_name(v: &Value) -> &'static str {
199    match v {
200        Value::Null => "null",
201        Value::Bool(_) => "a boolean",
202        Value::Number(_) => "a number",
203        Value::String(_) => "a string",
204        Value::Array(_) => "a list",
205        Value::Object(_) => "an object",
206    }
207}
208
209/// The `x-agentd-contract-version` the schema carries (ties to the capabilities
210/// manifest's `contract_version`, RFC 0014 §5 / RFC 0017 §4.2). Kept equal to the
211/// manifest's contract version by `tests::schema_contract_version_matches_manifest`.
212pub const SCHEMA_CONTRACT_VERSION: &str = "1.0";
213
214/// The deserialized config-file shape — one source of truth for the loader, the
215/// validator, and the `--config-schema` generator. `serde` only.
216///
217/// `deny_unknown_fields` rejects a typo'd key at parse time (exit 2). A flattened
218/// catch-all is INTENTIONALLY ABSENT — `deny_unknown_fields` is the guard.
219#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
220#[serde(deny_unknown_fields)]
221pub struct ConfigFile {
222    /// Optional; pins the file to a schema major agentctl validated against.
223    pub config_version: Option<String>,
224    /// `--intelligence` / `AGENTD_INTELLIGENCE` — the ordered intelligence
225    /// endpoint *list* URI (RFC 0018 §3.1). File-settable + **reloadable** so a
226    /// ConfigMap update can repoint the endpoint list as a hot-swap (RFC 0018 §5):
227    /// the reload fans `ctrl/swap_intel` to in-flight work and re-points new
228    /// spawns. The transport SCHEME is data, not a secret; the per-endpoint
229    /// credential is NEVER inline here (env/`_FILE` only, RFC 0012 §3.7).
230    pub intelligence: Option<String>,
231    /// `--model-swap` / `AGENTD_MODEL_SWAP` (RFC 0018 §5.3): the model hot-swap
232    /// policy (`finish-on-old` | `restart-turn`). Reloadable. Validated against
233    /// [`crate::config::SwapPolicy`].
234    pub model_swap: Option<String>,
235    /// `--model` / `AGENTD_MODEL` (reloadable param, never the transport).
236    pub model: Option<String>,
237    /// `--max-tokens` / `AGENTD_MAX_TOKENS`.
238    pub max_tokens: Option<u64>,
239    /// Bounds on the model loop (`--max-steps` / `--max-depth` / `--deadline`).
240    pub limits: Option<LimitsFile>,
241    /// The MCP server inventory — one object per `--mcp name=cmd … --mcp-tags …`.
242    #[serde(default)]
243    pub mcp_servers: Vec<McpServerFile>,
244    /// Declared subscriptions (reactive mode) — each string == one `--subscribe URI`.
245    #[serde(default)]
246    pub subscribe: Vec<String>,
247    /// Declared remote-A2A delegation peers — each == one `--a2a-peer name=endpoint`.
248    #[serde(default)]
249    pub a2a_peers: Vec<A2aPeerFile>,
250    /// `--log-level` / `AGENTD_LOG_LEVEL` (a string; validated against `Level`).
251    pub log_level: Option<String>,
252    /// Declared intelligence HTTP headers (RFC 0006 §3). Values MAY interpolate
253    /// `{{secret:NAME}}` / `{{secret-file:PATH}}` (§6); the resolved secret never
254    /// lands here or in a log. An inline secret-shaped value is rejected (§3.1).
255    #[serde(default)]
256    pub intelligence_headers: BTreeMap<String, String>,
257}
258
259/// The `limits` sub-object — maps to the per-run limit flags.
260#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
261#[serde(deny_unknown_fields)]
262pub struct LimitsFile {
263    /// `--max-steps`.
264    pub max_steps: Option<u32>,
265    /// `--max-depth`.
266    pub max_depth: Option<u32>,
267    /// `--deadline` in whole seconds.
268    pub deadline_secs: Option<u64>,
269    /// `--budget-tokens-lifetime` — the RFC 0025 per-instance cumulative token
270    /// cap across all runs/reactions (the CRD's `limits.lifetimeTokens`). `0` or
271    /// absent = unbounded.
272    pub lifetime_tokens: Option<u64>,
273}
274
275/// One MCP server, reached over the v2.0.0 Streamable HTTP transport: a remote
276/// `endpoint` (`https://host[:port][/path]`, loopback `http://` for dev; RFC 0004) with optional
277/// secret-free auth `headers` (RFC 0012 — no local process spawn). `tags` is the
278/// RFC 0012 §3.1 glob→tags wire (the loader flattens a `{"*": ["sensitive"]}` map
279/// to the server's tag set).
280#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
281#[serde(deny_unknown_fields)]
282pub struct McpServerFile {
283    pub name: String,
284    /// Remote MCP endpoint (the v2.0.0 transport).
285    pub endpoint: Option<String>,
286    /// Auth/framing header templates — values MAY interpolate `{{secret:NAME}}` /
287    /// `{{secret-file:PATH}}`, never inline secrets (RFC 0012 §3.7).
288    #[serde(default)]
289    pub headers: BTreeMap<String, String>,
290    /// Glob→trifecta-tags (RFC 0012 §3.1). An untagged server ⇒ `untrusted_input`.
291    #[serde(default)]
292    pub tags: BTreeMap<String, Vec<String>>,
293    /// Sign requests to this server with the AAuth agent identity (RFC 0023).
294    /// `None` inherits the global default (sign all when an identity is
295    /// configured); `false` opts out; `true` opts in. Needs `--features aauth`.
296    #[serde(default)]
297    pub aauth: Option<bool>,
298}
299
300/// One A2A peer — maps to `--a2a-peer name=endpoint` (RFC 0020 §3).
301#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
302#[serde(deny_unknown_fields)]
303pub struct A2aPeerFile {
304    pub name: String,
305    pub endpoint: String,
306    /// Secret-free auth header templates presented TO the peer (bearer leg),
307    /// e.g. `"authorization": "Bearer {{secret:PEER_TOKEN}}"`.
308    #[serde(default)]
309    pub headers: BTreeMap<String, String>,
310    /// Client-certificate PEM file paths for mutual TLS to the peer (both or
311    /// neither).
312    #[serde(default)]
313    pub client_cert: Option<String>,
314    #[serde(default)]
315    pub client_key: Option<String>,
316}
317
318/// The list of `ConfigFile` field names, in declaration order — the single
319/// source the schema generator and the drift test both read, so the schema's
320/// `properties` can never silently diverge from the struct (§4.2).
321pub const CONFIG_FILE_FIELDS: &[&str] = &[
322    "config_version",
323    "intelligence",
324    "model_swap",
325    "model",
326    "max_tokens",
327    "limits",
328    "mcp_servers",
329    "subscribe",
330    "a2a_peers",
331    "log_level",
332    "intelligence_headers",
333];
334
335impl ConfigFile {
336    /// Parse config text (YAML or JSON — sniffed, since there is no path). A
337    /// malformed document is an `Err` with a message the caller maps to exit 2
338    /// — before any side effect. JSON-with-comments is tolerated (`//` and
339    /// `/* */` are stripped first, matching the jsonc shown in the RFC set).
340    pub fn parse(text: &str) -> Result<ConfigFile, String> {
341        let doc = parse_document(text, Format::detect(None, text))?;
342        Self::from_document(doc, "config file")
343    }
344
345    /// Type a config DOCUMENT (from a file, or the env/flag path layers —
346    /// `source` names it in errors). Unknown keys are rejected
347    /// (`deny_unknown_fields`); the error names the offending key.
348    pub fn from_document(doc: Value, source: &str) -> Result<ConfigFile, String> {
349        serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))
350    }
351
352    /// Load + parse a config file from a local path (no network) — YAML or JSON
353    /// by extension, sniffed otherwise.
354    pub fn load(path: &str) -> Result<ConfigFile, String> {
355        let (doc, _format) = read_document(path)?;
356        Self::from_document(doc, "config file")
357    }
358}
359
360/// Strip line (`//`) and block (`/* */`) comments from JSON-with-comments,
361/// preserving string literals (a `//` inside a `"…"` is data, not a comment).
362/// Byte-oriented and minimal — the moat forbids a jsonc *crate*.
363///
364/// **Everything kept is copied as a SLICE of `src`, never as `byte as char`.**
365/// That distinction is the whole UTF-8 story: `0xE2 as char` is U+00E2 ('â'), so
366/// a byte-wise copy silently mojibake's an em-dash, an accented name or any CJK
367/// text into its Latin-1 shadow — and the result is still valid JSON, so nothing
368/// ever reports an error and the agent runs on a subtly wrong instruction.
369/// Slicing carries the whole multibyte sequence through untouched.
370///
371/// Scanning stays byte-wise, which is safe because every byte this function
372/// *matches on* (`"`, `\`, `/`, `*`, `\n`) is ASCII, and an ASCII byte can never
373/// occur inside a multibyte UTF-8 sequence (continuation bytes are all ≥ 0x80).
374/// So a comment boundary is always a char boundary and the slices below can
375/// never split a character.
376fn strip_jsonc(src: &str) -> String {
377    let bytes = src.as_bytes();
378    let mut out = String::with_capacity(src.len());
379    let mut i = 0;
380    let mut in_str = false;
381    // Start of the run of bytes not yet copied out. A run is broken only by a
382    // comment; everything else is emitted verbatim by slicing `src[run..i]`.
383    let mut run = 0;
384    while i < bytes.len() {
385        let b = bytes[i];
386        if in_str {
387            if b == b'\\' && i + 1 < bytes.len() {
388                // Skip the escape AND the escaped byte without inspecting it, so
389                // a \" cannot end the string. Both stay in the current run.
390                i += 2;
391                continue;
392            }
393            if b == b'"' {
394                in_str = false;
395            }
396            i += 1;
397            continue;
398        }
399        if b == b'"' {
400            in_str = true;
401            i += 1;
402            continue;
403        }
404        if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
405            // line comment → skip to end of line (keep the newline for line counts).
406            out.push_str(&src[run..i]);
407            while i < bytes.len() && bytes[i] != b'\n' {
408                i += 1;
409            }
410            run = i;
411            continue;
412        }
413        if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
414            // block comment → skip to the closing */.
415            out.push_str(&src[run..i]);
416            i += 2;
417            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
418                i += 1;
419            }
420            // Step past the `*/`. On an UNTERMINATED comment `i` is left mid-text,
421            // so clamp to the end — `bytes.len()` is always a char boundary, and
422            // the malformed document is serde_json's error to report, not ours.
423            i = (i + 2).min(bytes.len());
424            run = i;
425            continue;
426        }
427        i += 1;
428    }
429    out.push_str(&src[run..]);
430    out
431}
432
433/// Emit the hand-written **JSON Schema (Draft 2020-12)** of the config file
434/// (RFC 0017 §4.2). No `schemars` — a schema *library* is binary weight the moat
435/// forbids. Kept faithful to [`ConfigFile`] by `tests::schema_properties_match_struct_fields`.
436///
437/// `additionalProperties:false` mirrors `deny_unknown_fields`; `$id` pins the
438/// major; `x-agentd-contract-version` ties it to the manifest. agentctl
439/// validates a CR against this before applying it to a pod.
440pub fn config_schema() -> Value {
441    json!({
442        "$schema": "https://json-schema.org/draft/2020-12/schema",
443        "$id": format!("https://agentd.dev/schema/config/{SCHEMA_CONTRACT_VERSION}"),
444        "x-agentd-contract-version": SCHEMA_CONTRACT_VERSION,
445        "title": "agentd config file",
446        "type": "object",
447        "additionalProperties": false,
448        "properties": {
449            "config_version": { "type": "string" },
450            "intelligence": { "type": "string" },
451            "model_swap": { "enum": ["finish-on-old", "restart-turn"] },
452            "model": { "type": "string" },
453            "max_tokens": { "type": "integer", "minimum": 1 },
454            "limits": { "$ref": "#/$defs/Limits" },
455            "mcp_servers": { "type": "array", "items": { "$ref": "#/$defs/McpServer" } },
456            "subscribe": { "type": "array", "items": { "type": "string" } },
457            "a2a_peers": { "type": "array", "items": { "$ref": "#/$defs/A2aPeer" } },
458            "log_level": { "enum": ["trace", "debug", "info", "warn", "error"] },
459            "intelligence_headers": {
460                "type": "object",
461                "additionalProperties": { "type": "string" }
462            }
463        },
464        "$defs": {
465            "Limits": {
466                "type": "object",
467                "additionalProperties": false,
468                "properties": {
469                    "max_steps": { "type": "integer", "minimum": 1 },
470                    "max_depth": { "type": "integer", "minimum": 0 },
471                    "deadline_secs": { "type": "integer", "minimum": 0 },
472                    "lifetime_tokens": { "type": "integer", "minimum": 0 }
473                }
474            },
475            "McpServer": {
476                "type": "object",
477                "additionalProperties": false,
478                "required": ["name", "endpoint"],
479                "properties": {
480                    "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
481                    "endpoint": { "type": "string" },
482                    "headers": {
483                        "type": "object",
484                        "additionalProperties": { "type": "string" }
485                    },
486                    "tags": {
487                        "type": "object",
488                        "additionalProperties": {
489                            "type": "array",
490                            "items": { "enum": ["untrusted_input", "sensitive", "egress"] }
491                        }
492                    },
493                    "aauth": {
494                        "type": "boolean",
495                        "description": "sign requests to this server with the AAuth agent identity (RFC 0023); omit to inherit the global default"
496                    }
497                }
498            },
499            "A2aPeer": {
500                "type": "object",
501                "additionalProperties": false,
502                "required": ["name", "endpoint"],
503                "properties": {
504                    "name": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
505                    "endpoint": { "type": "string" },
506                    "headers": {
507                        "type": "object",
508                        "additionalProperties": { "type": "string" },
509                        "description": "secret-free auth header templates presented to the peer ({{secret:NAME}} references)"
510                    },
511                    "client_cert": { "type": "string", "description": "client certificate PEM file path (mutual TLS to the peer; requires client_key)" },
512                    "client_key": { "type": "string", "description": "client private-key PEM file path" }
513                }
514            }
515        }
516    })
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn parses_a_full_file() {
525        let src = r#"{
526            "config_version": "1.0",
527            "model": "claude-opus-4",
528            "max_tokens": 2000000,
529            "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
530            "mcp_servers": [
531                { "name": "web", "endpoint": "https://web.example.com/mcp",
532                  "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
533                  "tags": { "*": ["untrusted_input"] } }
534            ],
535            "subscribe": ["fs:file:///watch/inbox"],
536            "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
537            "log_level": "info",
538            "intelligence_headers": { "anthropic-version": "2023-06-01" }
539        }"#;
540        let cf = ConfigFile::parse(src).unwrap();
541        assert_eq!(cf.model.as_deref(), Some("claude-opus-4"));
542        assert_eq!(cf.max_tokens, Some(2_000_000));
543        assert_eq!(cf.limits.unwrap().max_steps, Some(200));
544        assert_eq!(cf.mcp_servers.len(), 1);
545        assert_eq!(
546            cf.mcp_servers[0].endpoint.as_deref(),
547            Some("https://web.example.com/mcp")
548        );
549        assert_eq!(cf.subscribe, vec!["fs:file:///watch/inbox"]);
550        assert_eq!(cf.a2a_peers[0].name, "mesh");
551        assert_eq!(cf.log_level.as_deref(), Some("info"));
552    }
553
554    #[test]
555    fn unknown_key_is_rejected() {
556        // deny_unknown_fields: a typo'd key is a hard error, not silently ignored.
557        let e = ConfigFile::parse(r#"{ "max_token": 5 }"#).unwrap_err();
558        assert!(e.contains("parse error"), "got: {e}");
559        assert!(e.contains("max_token"), "names the key: {e}");
560        // Same for YAML — the typo is named, whatever the syntax.
561        let e = ConfigFile::parse("max_token: 5\n").unwrap_err();
562        assert!(
563            e.contains("parse error") && e.contains("max_token"),
564            "got: {e}"
565        );
566    }
567
568    #[test]
569    fn yaml_and_json_documents_type_identically() {
570        let yaml = r#"
571# the same document as parses_a_full_file, in YAML
572config_version: "1.0"
573model: claude-opus-4
574max_tokens: 2000000
575limits:
576  max_steps: 200
577  max_depth: 4
578  deadline_secs: 600
579mcp_servers:
580  - name: web
581    endpoint: https://web.example.com/mcp
582    headers:
583      Authorization: "Bearer {{secret:WEB_TOKEN}}"
584    tags:
585      "*": [untrusted_input]
586subscribe: [fs:file:///watch/inbox]
587a2a_peers:
588  - name: mesh
589    endpoint: unix:/run/peer.sock
590log_level: info
591intelligence_headers:
592  anthropic-version: "2023-06-01"
593"#;
594        let json = r#"{
595            "config_version": "1.0",
596            "model": "claude-opus-4",
597            "max_tokens": 2000000,
598            "limits": { "max_steps": 200, "max_depth": 4, "deadline_secs": 600 },
599            "mcp_servers": [
600                { "name": "web", "endpoint": "https://web.example.com/mcp",
601                  "headers": { "Authorization": "Bearer {{secret:WEB_TOKEN}}" },
602                  "tags": { "*": ["untrusted_input"] } }
603            ],
604            "subscribe": ["fs:file:///watch/inbox"],
605            "a2a_peers": [{ "name": "mesh", "endpoint": "unix:/run/peer.sock" }],
606            "log_level": "info",
607            "intelligence_headers": { "anthropic-version": "2023-06-01" }
608        }"#;
609        let from_yaml = ConfigFile::parse(yaml).expect("yaml parses");
610        let from_json = ConfigFile::parse(json).expect("json parses");
611        assert_eq!(from_yaml, from_json, "one document model, two syntaxes");
612        assert_eq!(from_yaml.limits.as_ref().unwrap().max_steps, Some(200));
613        assert_eq!(from_yaml.mcp_servers[0].tags["*"], vec!["untrusted_input"]);
614    }
615
616    #[test]
617    fn format_detection_by_extension_then_sniff() {
618        assert_eq!(
619            Format::detect(Some(Path::new("/etc/agentd/config.yaml")), "{}"),
620            Format::Yaml
621        );
622        assert_eq!(Format::detect(Some(Path::new("c.YML")), "{}"), Format::Yaml);
623        assert_eq!(
624            Format::detect(Some(Path::new("c.json")), "model: x"),
625            Format::Json
626        );
627        assert_eq!(
628            Format::detect(Some(Path::new("c.jsonc")), "model: x"),
629            Format::Json
630        );
631        // Unknown extension / no path: sniff the first significant character.
632        assert_eq!(
633            Format::detect(Some(Path::new("agentd.conf")), "  { \"a\": 1 }"),
634            Format::Json
635        );
636        assert_eq!(Format::detect(None, "// jsonc\n{ \"a\": 1 }"), Format::Json);
637        assert_eq!(Format::detect(None, "/* c */ [1]"), Format::Json);
638        assert_eq!(Format::detect(None, "# yaml\nmodel: x\n"), Format::Yaml);
639        assert_eq!(Format::detect(None, "model: x\n"), Format::Yaml);
640        assert_eq!(Format::detect(None, ""), Format::Yaml);
641    }
642
643    #[test]
644    fn merge_follows_json_merge_patch() {
645        let mut base = json!({
646            "model": "base",
647            "limits": {"max_steps": 1, "max_depth": 2},
648            "subscribe": ["a", "b"],
649            "intelligence_headers": {"h1": "v1"},
650            "log_level": "info"
651        });
652        merge_into(
653            &mut base,
654            json!({
655                "model": "over",                    // scalar: replaced
656                "limits": {"max_steps": 9},         // object: merged (max_depth kept)
657                "subscribe": ["c"],                 // list: REPLACED, not appended
658                "intelligence_headers": {"h2": "v2"}, // map: merged
659                "log_level": null                   // null: unset
660            }),
661        );
662        assert_eq!(
663            base,
664            json!({
665                "model": "over",
666                "limits": {"max_steps": 9, "max_depth": 2},
667                "subscribe": ["c"],
668                "intelligence_headers": {"h1": "v1", "h2": "v2"}
669            })
670        );
671        // A scalar in the way of an object overlay is replaced by the object.
672        let mut base = json!({"limits": 5});
673        merge_into(&mut base, json!({"limits": {"max_steps": 1}}));
674        assert_eq!(base, json!({"limits": {"max_steps": 1}}));
675    }
676
677    #[test]
678    fn multiple_files_merge_in_order_later_wins() {
679        let dir = tempfile::tempdir().unwrap();
680        let base = dir.path().join("base.yaml");
681        let prod = dir.path().join("prod.yaml");
682        let extra = dir.path().join("extra.json");
683        std::fs::write(
684            &base,
685            "model: base\nlimits:\n  max_steps: 1\n  max_depth: 2\nsubscribe: [a, b]\n",
686        )
687        .unwrap();
688        std::fs::write(
689            &prod,
690            "model: prod\nlimits:\n  max_steps: 9\nsubscribe: [c]\n",
691        )
692        .unwrap();
693        std::fs::write(
694            &extra,
695            r#"{ "log_level": "warn", "limits": { "max_depth": null } }"#,
696        )
697        .unwrap();
698        let paths: Vec<String> = [&base, &prod, &extra]
699            .iter()
700            .map(|p| p.to_str().unwrap().to_string())
701            .collect();
702        let (doc, loaded) = read_documents(&paths).unwrap();
703        assert_eq!(
704            doc,
705            json!({
706                "model": "prod",
707                "limits": {"max_steps": 9},
708                "subscribe": ["c"],
709                "log_level": "warn"
710            })
711        );
712        assert_eq!(loaded.len(), 3);
713        assert_eq!(loaded[0].1, Format::Yaml);
714        assert_eq!(loaded[2].1, Format::Json);
715        // An unknown key is attributed to the file that carries it.
716        std::fs::write(&prod, "modle: typo\n").unwrap();
717        let e = read_documents(&paths).unwrap_err();
718        assert!(e.contains("prod.yaml") && e.contains("modle"), "{e}");
719        // A missing file is an error naming it.
720        let e = read_documents(&["/no/such/agentd.yaml".to_string()]).unwrap_err();
721        assert!(e.contains("/no/such/agentd.yaml"), "{e}");
722    }
723
724    #[test]
725    fn a_non_mapping_document_is_rejected() {
726        let e = parse_document("- a\n- b\n", Format::Yaml).unwrap_err();
727        assert!(e.contains("mapping"), "{e}");
728        let e = parse_document("[1, 2]", Format::Json).unwrap_err();
729        assert!(e.contains("mapping"), "{e}");
730        // An empty YAML file is an empty config (nothing set) — not an error.
731        assert_eq!(
732            parse_document("# nothing yet\n", Format::Yaml).unwrap(),
733            json!({})
734        );
735        // A YAML syntax error names the line.
736        let e = parse_document("a: 1\n\tb: 2\n", Format::Yaml).unwrap_err();
737        assert!(e.contains("(yaml)") && e.contains("line 2"), "{e}");
738    }
739
740    #[test]
741    fn malformed_json_is_an_error() {
742        assert!(ConfigFile::parse("{ not json").is_err());
743    }
744
745    #[test]
746    fn jsonc_comments_are_stripped() {
747        let src = r#"{
748            // a line comment
749            "model": "m", /* block */ "max_tokens": 10,
750            "subscribe": ["http://x//path"]  // a // inside a string is data
751        }"#;
752        let cf = ConfigFile::parse(src).unwrap();
753        assert_eq!(cf.model.as_deref(), Some("m"));
754        assert_eq!(cf.max_tokens, Some(10));
755        // The `//` inside the string literal survived (not treated as a comment).
756        assert_eq!(cf.subscribe, vec!["http://x//path"]);
757    }
758
759    #[test]
760    fn non_ascii_round_trips_through_the_jsonc_stripper() {
761        // Silent corruption is the worst failure mode: mojibake'd text is still
762        // valid JSON, so a byte-wise stripper would hand the agent a subtly wrong
763        // instruction with nothing reporting an error. Every string here must come
764        // back byte-identical, INCLUDING the ones pressed up against a comment —
765        // that adjacency is exactly where a byte-wise stripper splits a sequence.
766        let model = "Ünïcøde — 日本語 μοντέλο";
767        let src = format!(
768            "{{\n  /* 日本語 block */\"model\": \"{model}\",/*é*/\n  \"subscribe\": [\"fs:file:///wätch/收件箱\"] // — trailing 日本語\n}}"
769        );
770        let cf = ConfigFile::parse(&src).unwrap();
771        assert_eq!(cf.model.as_deref(), Some(model), "mojibake in the value");
772        assert_eq!(cf.subscribe, vec!["fs:file:///wätch/收件箱"]);
773        // The stripper itself must be the identity on a comment-free document.
774        let plain = format!("{{ \"model\": \"{model}\" }}");
775        assert_eq!(strip_jsonc(&plain), plain);
776        // A \-escape adjacent to multibyte text must not eat the following byte.
777        let cf = ConfigFile::parse("{ \"model\": \"a\\\"—\\\\é\" }").unwrap();
778        assert_eq!(cf.model.as_deref(), Some("a\"—\\é"));
779    }
780
781    #[test]
782    fn schema_is_parseable_draft_2020_12() {
783        let s = config_schema();
784        assert_eq!(
785            s["$schema"],
786            json!("https://json-schema.org/draft/2020-12/schema")
787        );
788        assert_eq!(s["additionalProperties"], json!(false));
789        assert_eq!(
790            s["x-agentd-contract-version"],
791            json!(SCHEMA_CONTRACT_VERSION)
792        );
793        // It round-trips through serde_json as a valid document.
794        let text = serde_json::to_string(&s).unwrap();
795        let _: Value = serde_json::from_str(&text).unwrap();
796    }
797
798    #[test]
799    fn schema_properties_match_struct_fields() {
800        // The hand-written schema cannot silently drift from the struct: its
801        // top-level `properties` keys must be EXACTLY the struct's fields.
802        let s = config_schema();
803        let props = s["properties"].as_object().unwrap();
804        let schema_keys: std::collections::BTreeSet<&str> =
805            props.keys().map(String::as_str).collect();
806        let struct_keys: std::collections::BTreeSet<&str> =
807            CONFIG_FILE_FIELDS.iter().copied().collect();
808        assert_eq!(
809            schema_keys, struct_keys,
810            "schema properties drifted from ConfigFile fields"
811        );
812    }
813
814    #[test]
815    fn config_file_fields_const_matches_a_full_deser() {
816        // Guard the CONFIG_FILE_FIELDS const itself: a fully-populated JSON object
817        // keyed by every const entry must deserialize (so a renamed/added struct
818        // field forces the const + schema to be updated together).
819        let mut obj = serde_json::Map::new();
820        for k in CONFIG_FILE_FIELDS {
821            let v = match *k {
822                "config_version" | "model" | "log_level" | "intelligence" => json!("x"),
823                "model_swap" => json!("finish-on-old"),
824                "max_tokens" => json!(1),
825                "limits" => json!({}),
826                "mcp_servers" => json!([{ "name": "a", "endpoint": "unix:/a.sock" }]),
827                "subscribe" => json!(["u"]),
828                "a2a_peers" => json!([{ "name": "p", "endpoint": "unix:/x" }]),
829                "intelligence_headers" => json!({ "h": "v" }),
830                other => panic!("CONFIG_FILE_FIELDS has an unmapped key {other}"),
831            };
832            obj.insert((*k).to_string(), v);
833        }
834        let text = serde_json::to_string(&Value::Object(obj)).unwrap();
835        ConfigFile::parse(&text).expect("every CONFIG_FILE_FIELDS key must deserialize");
836    }
837}