Skip to main content

agent_doc_core/
frontmatter.rs

1//! # Module: frontmatter
2//!
3//! ## Spec
4//! - Defines the YAML frontmatter schema (`Frontmatter`) embedded between `---\n` delimiters at
5//!   the top of agent-doc documents.
6//! - `parse(content)` splits a document string into `(Frontmatter, body_str)`. Documents that do
7//!   not start with `---\n` return a default `Frontmatter` and the full content as body.
8//!   Unterminated frontmatter (no closing `---`) returns `Err`.
9//! - `parse_for_file(content, file)` is the path-aware variant: it wraps YAML parse failures with
10//!   the document path, resolves project-local required SSH defaults from `.agent-doc/config.toml`,
11//!   and fails closed when a configured SSH-dependent document cannot resolve any targets.
12//! - `write(fm, body)` serialises `Frontmatter` to YAML and prepends it to `body`, producing a
13//!   complete document string.
14//! - `set_session_id` / `set_resume_id` are convenience wrappers: parse → mutate one field →
15//!   write. They create frontmatter when none exists.
16//! - `ensure_session` is idempotent: if `session` is already set the document is returned
17//!   unchanged; otherwise a UUID v4 is generated and written.
18//! - `set_format_and_write` atomically sets `agent_doc_format` + `agent_doc_write` and clears
19//!   the deprecated `agent_doc_mode` field.
20//! - `merge_fields(content, yaml_fields)` applies additive key/value patches to the frontmatter;
21//!   unknown keys are logged to stderr and discarded; the document body is preserved unchanged.
22//! - `set_tmux_session` is retained for backward compatibility; `tmux_session` in frontmatter is
23//!   deprecated — runtime session is now determined by `--window` or `current_tmux_session()`.
24//! - `AgentDocFormat` (append | template) controls document structure; serialised as `inline` /
25//!   `template` in YAML; `inline` and `append` are accepted as aliases on deserialise.
26//! - `AgentDocWrite` (merge | crdt) controls the write/merge strategy.
27//! - `ResolvedMode` is the canonical (format, write) pair after deprecation migration.
28//!   `Frontmatter::resolve_mode()` applies a three-level priority: explicit `agent_doc_format` /
29//!   `agent_doc_write` fields > deprecated `agent_doc_mode` string > defaults
30//!   (format=template, write=crdt).
31//! - Deprecated `agent_doc_mode` values: `"append"` → format=Append; `"template"` or `"stream"`
32//!   → format=Template; all keep write=Crdt.
33//! - `StreamConfig` carries optional CRDT stream parameters: write-back interval, ANSI stripping,
34//!   target component, and chain-of-thought routing.
35//! - `prompt_presets` stores reusable named prompt snippets for orchestrated tasks and round-trips
36//!   exact preset names/values, including symbolic keys such as `#1`.
37//! - `codex_network_access` stores an explicit Codex network policy (`inherit`, `enabled`,
38//!   `disabled`) so agent-doc can own the `CODEX_SANDBOX_NETWORK_DISABLED` contract instead of
39//!   silently inheriting an ambient launcher override.
40//! - Field renames and aliases ensure backward compatibility:
41//!   `session` / `agent_doc_session` → `session`;
42//!   `mode` / `response_mode` / `agent_doc_mode` → `mode`.
43//!
44//! ## Agentic Contracts
45//! - `parse` never panics; it returns `Err` only for an unterminated frontmatter block or a YAML
46//!   deserialisation failure. All missing optional fields default to `None`.
47//! - `write(parse(doc).0, parse(doc).1)` round-trips the document: re-parsing the result yields
48//!   semantically equivalent `Frontmatter` and an identical body string, without accumulating
49//!   blank lines at the start of the body.
50//! - `ensure_session` is idempotent: calling it twice on the same document returns the same
51//!   session ID and does not modify the document on the second call.
52//! - `merge_fields` is additive: it never removes existing fields unless an explicit `null`
53//!   value is supplied for that field in `yaml_fields`.
54//! - `set_format_and_write` is the only function that actively clears a field (`mode = None`);
55//!   all other helpers are strictly additive.
56//! - `resolve_mode` is pure and total: it always returns a valid `ResolvedMode` regardless of
57//!   which combination of deprecated and current fields are populated.
58//! - `prompt_presets` is deterministic: parse/write preserves preset names and bodies exactly,
59//!   including multiline values and symbolic names like `#1`.
60//! - `codex_network_access` round-trips as a stable enum and is preserved across
61//!   parse/write/merge cycles.
62//! - Serialised YAML always uses the canonical field names (`agent_doc_session`,
63//!   `agent_doc_mode`, `agent_doc_format`, `agent_doc_write`); legacy aliases are read-only.
64//!
65//! ## Evals
66//! - parse_no_frontmatter: content without `---` prefix → default Frontmatter, full content as body
67//! - parse_all_fields: document with all known fields → each field correctly populated
68//! - parse_null_fields: YAML `null` values → all fields `None`
69//! - parse_unterminated_frontmatter: `---` open with no close → `Err` "Unterminated frontmatter"
70//! - parse_closing_at_eof: closing `---` without trailing newline → body = ""
71//! - write_roundtrip: write then parse → identical Frontmatter and identical body
72//! - repeated_parse_write_roundtrip_preserves_body_prefix: repeated parse/write cycles preserve
73//!   the exact body prefix and do not accumulate blank lines before the first heading
74//! - write_default_frontmatter: default Frontmatter → output starts with `---\n`, ends with `---\n`
75//! - set_session_id_creates_frontmatter: plain doc → frontmatter added with correct session
76//! - set_session_id_preserves_other_fields: existing fields not clobbered by session update
77//! - ensure_session_no_frontmatter: plain doc → UUID generated, written, returned
78//! - ensure_session_existing_session: session already set → content unchanged, same ID returned
79//! - parse_legacy_session_field: `session:` alias → populates `fm.session`
80//! - parse_mode_shorthand_alias: `mode:` alias → populates `fm.mode`
81//! - write_uses_agent_doc_mode_field: deprecated mode serialised as `agent_doc_mode:`, not `mode:`
82//! - write_uses_new_field_name: session serialised as `agent_doc_session:`, not `session:`
83//! - resolve_mode_defaults: empty Frontmatter → format=Template, write=Crdt
84//! - resolve_mode_new_fields_override_deprecated: both present → new fields win
85//! - set_format_and_write_clears_deprecated_mode: deprecated `mode` cleared after migration
86//! - merge_fields_adds_new_field: new key added without disturbing existing fields
87//! - merge_fields_ignores_unknown: unknown key logged to stderr, not stored
88//! - parse_prompt_presets_roundtrip: preset map with multiline values → parse/write preserves keys
89//!   and bodies exactly
90//! - parse_codex_network_access_field: `codex_network_access: enabled` → enum value
91//! - resolve_harness_model: harness-specific model fields override `model`
92//!   for `claude-code`, `codex`, and `opencode`
93//! - parse_harness_model_fields: harness model fields round-trip through parse/write
94//! - merge_fields_claude_model / merge_fields_codex_model / merge_fields_opencode_model:
95//!   merge patches for harness model fields
96
97use anyhow::Result;
98use serde::{Deserialize, Serialize};
99use std::fmt;
100use uuid::Uuid;
101
102use crate::model_tier::Tier;
103
104#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum CollaborationMode {
107    #[default]
108    SingleUser,
109    Shared,
110}
111
112#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum CodexNetworkAccess {
115    #[default]
116    Inherit,
117    Enabled,
118    Disabled,
119}
120
121/// Document format: controls document structure.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
123pub enum AgentDocFormat {
124    /// Alternating ## User / ## Assistant blocks (also known as "inline")
125    #[clap(alias = "inline")]
126    Append,
127    /// In-place component patching with <!-- agent:name --> markers
128    Template,
129}
130
131impl fmt::Display for AgentDocFormat {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::Append => write!(f, "inline"),
135            Self::Template => write!(f, "template"),
136        }
137    }
138}
139
140impl Serialize for AgentDocFormat {
141    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
142    where
143        S: serde::Serializer,
144    {
145        match self {
146            Self::Append => serializer.serialize_str("inline"),
147            Self::Template => serializer.serialize_str("template"),
148        }
149    }
150}
151
152impl<'de> Deserialize<'de> for AgentDocFormat {
153    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
154    where
155        D: serde::Deserializer<'de>,
156    {
157        let s = String::deserialize(deserializer)?;
158        match s.as_str() {
159            "append" | "inline" => Ok(Self::Append),
160            "template" => Ok(Self::Template),
161            other => Err(serde::de::Error::unknown_variant(
162                other,
163                &["inline", "append", "template"],
164            )),
165        }
166    }
167}
168
169/// Write strategy: controls how responses are merged into the document.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
171#[serde(rename_all = "lowercase")]
172pub enum AgentDocWrite {
173    /// 3-way merge via git merge-file
174    Merge,
175    /// CRDT-based conflict-free merge (yrs)
176    Crdt,
177}
178
179impl fmt::Display for AgentDocWrite {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::Merge => write!(f, "merge"),
183            Self::Crdt => write!(f, "crdt"),
184        }
185    }
186}
187
188/// Resolved mode pair — the canonical representation after deprecation migration.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct ResolvedMode {
191    pub format: AgentDocFormat,
192    pub write: AgentDocWrite,
193}
194
195impl ResolvedMode {
196    pub fn is_template(&self) -> bool {
197        self.format == AgentDocFormat::Template
198    }
199
200    pub fn is_append(&self) -> bool {
201        self.format == AgentDocFormat::Append
202    }
203
204    pub fn is_crdt(&self) -> bool {
205        self.write == AgentDocWrite::Crdt
206    }
207}
208
209/// Configuration for stream mode (real-time CRDT write-back).
210#[derive(Debug, Default, Clone, Serialize, Deserialize)]
211pub struct StreamConfig {
212    /// Write-back interval in milliseconds (default: 200)
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub interval: Option<u64>,
215    /// Strip ANSI escape codes from agent output (default: true)
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub strip_ansi: Option<bool>,
218    /// Target component name for stream output (default: "exchange")
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub target: Option<String>,
221    /// Include chain-of-thought (thinking) blocks in output (default: false)
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub thinking: Option<bool>,
224    /// Route thinking to a separate component (e.g., "log"). If unset, thinking
225    /// is interleaved with response text in the target component.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub thinking_target: Option<String>,
228    /// Maximum lines for console capture (default: 50). Limits the content
229    /// written to the console component to prevent indefinite growth.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub max_lines: Option<usize>,
232}
233
234#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum PendingCaptureGuardMode {
237    #[default]
238    Warn,
239    #[serde(alias = "error")]
240    Strict,
241    Off,
242}
243
244/// Lint dialect mode for the `tagpath lint --dialect agent-doc` finalize gate.
245///
246/// Resolution precedence: CLI `--lint=...` > frontmatter
247/// `agent_doc_lint_dialect` > workspace `.agent-doc/config.toml` `[lint]
248/// dialect` > default (`warn`).
249///
250/// - `Warn` (default): lint runs; errors block the cycle, warnings surface on
251///   stderr but do not block.
252/// - `Strict`: warnings are promoted to errors.
253/// - `Off`: lint gate is skipped entirely. Used to migrate documents with
254///   historical malformed directives while staging fixes.
255#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "lowercase")]
257pub enum LintDialectMode {
258    #[default]
259    Warn,
260    #[serde(alias = "error")]
261    Strict,
262    Off,
263}
264
265#[derive(Debug, Default, Clone, Serialize, Deserialize)]
266pub struct Frontmatter {
267    /// Document/routing UUID — permanent identifier for tmux pane routing.
268    /// Serialized as `agent_doc_session` in YAML; reads legacy `session` via alias.
269    #[serde(
270        default,
271        skip_serializing_if = "Option::is_none",
272        rename = "agent_doc_session",
273        alias = "session"
274    )]
275    pub session: Option<String>,
276    /// Agent conversation ID — used for `--resume` with agent backends.
277    /// Separate from `session` so the routing key never changes.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub resume: Option<String>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub agent: Option<String>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub model: Option<String>,
284    /// Per-harness model override for Claude Code sessions.
285    /// When running under Claude Code, this takes precedence over `model`.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub claude_model: Option<String>,
288    /// Per-harness model override for Codex sessions.
289    /// When running under Codex, this takes precedence over `model`.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub codex_model: Option<String>,
292    /// Per-harness model override for OpenCode sessions.
293    /// When running under OpenCode, this takes precedence over `model`.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub opencode_model: Option<String>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub branch: Option<String>,
298    /// **Deprecated.** Tmux session name for pane affinity (e.g., "claude").
299    /// Session is now determined at runtime by `--window` argument (sync) or
300    /// `current_tmux_session()` (route/start). Still read for backward compatibility
301    /// and auto-repaired by sync when it differs from the context session.
302    /// Will be removed in a future version.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub tmux_session: Option<String>,
305    /// **Deprecated.** Use `agent_doc_format` + `agent_doc_write` instead.
306    /// Kept for backward compatibility. Values: "append", "template", "stream".
307    /// Serialized as `agent_doc_mode` in YAML; reads legacy `response_mode` and shorthand `mode` via aliases.
308    #[serde(
309        default,
310        skip_serializing_if = "Option::is_none",
311        rename = "agent_doc_mode",
312        alias = "mode",
313        alias = "response_mode"
314    )]
315    pub mode: Option<String>,
316    /// Document format: controls document structure (append | template).
317    #[serde(
318        default,
319        skip_serializing_if = "Option::is_none",
320        rename = "agent_doc_format"
321    )]
322    pub format: Option<AgentDocFormat>,
323    /// Write strategy: controls merge behavior (merge | crdt).
324    #[serde(
325        default,
326        skip_serializing_if = "Option::is_none",
327        rename = "agent_doc_write"
328    )]
329    pub write_mode: Option<AgentDocWrite>,
330    /// Stream mode configuration (used when write strategy is CRDT).
331    #[serde(
332        default,
333        skip_serializing_if = "Option::is_none",
334        rename = "agent_doc_stream"
335    )]
336    pub stream_config: Option<StreamConfig>,
337    /// Additional CLI arguments to pass to the agent process (harness-neutral).
338    /// Takes precedence over harness-specific aliases. Space-separated string.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub agent_args: Option<String>,
341    /// Additional CLI arguments to pass to the `claude` process.
342    /// Backward-compatible alias for `agent_args`. `agent_args` takes precedence when both are set.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub claude_args: Option<String>,
345    /// Additional CLI arguments to pass to the `codex` process.
346    /// Codex-only alias for `agent_args`. `agent_args` takes precedence when both are set.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub codex_args: Option<String>,
349    /// Additional CLI arguments to pass to the `opencode` process.
350    /// OpenCode-only alias for `agent_args`. `agent_args` takes precedence when both are set.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub opencode_args: Option<String>,
353    /// Explicit Codex network policy. `inherit` keeps the launcher's ambient
354    /// `CODEX_SANDBOX_NETWORK_DISABLED` setting, `enabled` removes it, and
355    /// `disabled` forces it on for the child session.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub codex_network_access: Option<CodexNetworkAccess>,
358    /// Required SSH targets that must be reachable for Codex-backed work to
359    /// proceed. When present, agent-doc probes these targets before trusting a
360    /// resumed Codex session and fails closed when the capability cannot be
361    /// proven.
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub required_ssh_targets: Vec<String>,
364    /// Optional profile name resolved via project-local `.agent-doc/config.toml`
365    /// SSH mappings when the document omits direct `required_ssh_targets`.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub required_ssh_profile: Option<String>,
368    /// When true, passes `--no-mcp` to the `claude` process.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub no_mcp: Option<bool>,
371    /// When true, passes `--enable-tool-search` to the `claude` process.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub enable_tool_search: Option<bool>,
374    /// Debounce duration in milliseconds for preflight mtime settling.
375    /// Default: 2000ms. Set to 0 to disable debounce (run immediately).
376    #[serde(
377        default,
378        skip_serializing_if = "Option::is_none",
379        rename = "agent_doc_debounce"
380    )]
381    pub debounce_ms: Option<u64>,
382    /// Linked resources: local file paths or URLs (relative to this document's directory).
383    /// Changes in linked docs are surfaced during preflight.
384    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
385    pub links: Vec<String>,
386    /// Explicit opt-in for automatic compaction/reload policies.
387    /// Session-accretion heuristics never compact by themselves; omit to disable.
388    #[serde(
389        default,
390        skip_serializing_if = "Option::is_none",
391        rename = "agent_doc_auto_compact"
392    )]
393    pub auto_compact: Option<usize>,
394    /// Required model tier for this document. When set, preflight emits this as
395    /// `required_tier`, which the skill uses as a hard gate: if the running model's
396    /// tier is below this value, the skill writes a switch prompt and stops.
397    /// Values: `auto | low | med | high`. Default: absent (no gate).
398    #[serde(
399        default,
400        skip_serializing_if = "Option::is_none",
401        rename = "agent_doc_model_tier"
402    )]
403    pub model_tier: Option<Tier>,
404    /// Pending-capture guard mode for `session-check`.
405    /// Values: `warn` (default when absent), `strict`, `off`.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub pending_capture_guard: Option<PendingCaptureGuardMode>,
408    /// Pending-done guard mode for `session-check`.
409    /// Values: `warn` (default when absent), `strict`, `off`.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub pending_done_guard: Option<PendingCaptureGuardMode>,
412    /// Review-done guard mode for `--done`.
413    /// Values: `off` (default when absent), `warn`, `strict`/`error`.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub review_done_guard: Option<PendingCaptureGuardMode>,
416    /// When true, closeout may auto-apply `--done` for clear completion signals.
417    /// Default is absent/false; explicit `--done` and explicit `#id done`
418    /// directives still work without this opt-in.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub auto_done: Option<bool>,
421    /// Lint dialect mode for the agent-doc finalize lint gate. Values:
422    /// `warn` (default), `strict`, `off`. See `lint_gate` module for the
423    /// full precedence chain.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub agent_doc_lint_dialect: Option<LintDialectMode>,
426    /// Document-level lifecycle hooks: shell commands executed at key events.
427    ///
428    /// Supported events: `session_start`, `post_write`, `post_commit`.
429    /// Each event maps to a list of shell commands (run via `sh -c`).
430    ///
431    /// Template variables substituted before execution:
432    /// - `{{session_id}}` — document session UUID
433    /// - `{{file}}` — document file path
434    /// - `{{agent}}` — agent name (or empty string)
435    /// - `{{model}}` — model name (or empty string)
436    ///
437    /// Execution is best-effort: failures log to stderr and never block the session.
438    ///
439    /// Example:
440    /// ```yaml
441    /// hooks:
442    ///   session_start:
443    ///     - "curl -s -X POST https://api.example.com/sessions -d '{\"session_id\": \"{{session_id}}\"}'"
444    ///   post_write:
445    ///     - "notify-send 'agent-doc' 'Response written to {{file}}'"
446    /// ```
447    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
448    pub hooks: std::collections::HashMap<String, Vec<String>>,
449    /// Environment variables to inject into the Claude session.
450    /// Keys are env var names, values are strings supporting shell expansion
451    /// (`$(command)`, `$VAR`, `${VAR}`). Order is preserved: later values can
452    /// reference earlier keys. A `null` value (YAML `KEY: null`) means "unset
453    /// this key in the child env" — supported by every consumer (start.rs,
454    /// run.rs, stream.rs, parallel.rs, supervisor/env.rs).
455    #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
456    pub env: indexmap::IndexMap<String, Option<String>>,
457    /// Reusable prompt snippets that can be expanded into orchestrated task prompts.
458    ///
459    /// Example:
460    /// ```yaml
461    /// prompt_presets:
462    ///   "#1": |
463    ///     Today is 2026-04-25.
464    ///     Keep the work tree clean.
465    /// ```
466    #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
467    pub prompt_presets: indexmap::IndexMap<String, String>,
468    /// Lower-agent dispatch policy for planning/job-packet generation.
469    /// Values are currently interpreted by `plan` as manual/auto/off text;
470    /// unknown values round-trip so newer policy names do not break older binaries.
471    #[serde(
472        default,
473        skip_serializing_if = "Option::is_none",
474        rename = "agent_doc_dispatch"
475    )]
476    pub dispatch: Option<String>,
477    /// Whether the supervisor starts from the parent process env before
478    /// applying the `env:` overlay. Default `true` — the supervised claude
479    /// child inherits PATH/HOME/TERM/TMUX/... from the shell that launched
480    /// agent-doc, then frontmatter `env:` overrides/unsets specific keys.
481    /// Set `false` for a sealed env (only the explicit `env:` keys, nothing
482    /// from the parent). Consumed by `supervisor/env.rs::EnvSpec`.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub agent_doc_env_inherit: Option<bool>,
485    /// Explicit working directory for the supervised claude child process.
486    /// Resolved relative to the document's parent directory if a relative path
487    /// is given. When absent, the supervisor falls back to project-root detection
488    /// (`.agent-doc/` walk) or the document's parent directory.
489    /// See `src/agent-doc/specs/supervisor.md` § Core Invariants / CWD determinism.
490    #[serde(
491        default,
492        skip_serializing_if = "Option::is_none",
493        rename = "agent_doc_cwd"
494    )]
495    pub cwd: Option<String>,
496    /// Whether the queue is currently active (consuming prompts).
497    /// Set to `true` when the queue is activated via `auto`, start fence, or
498    /// `do queue`/`run queue`. Cleared when the queue drains to empty.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub queue_active: Option<bool>,
501    /// Collaboration scope for the document.
502    /// `shared` opts the document into stricter cross-document security checks.
503    #[serde(
504        default,
505        skip_serializing_if = "Option::is_none",
506        rename = "agent_doc_collaboration"
507    )]
508    pub collaboration: Option<CollaborationMode>,
509    /// Audit note / approval token for cross-document reads or transfers in shared mode.
510    #[serde(
511        default,
512        skip_serializing_if = "Option::is_none",
513        rename = "agent_doc_security_review"
514    )]
515    pub security_review: Option<String>,
516}
517
518/// Resolve a user-authored prompt preset reference to the frontmatter key.
519///
520/// Exact keys win. As a convenience for command-like preset names, a bare
521/// reference such as `preset review` also resolves to a `#review` key when
522/// only the hashtag form is defined.
523pub fn resolve_prompt_preset_key(
524    prompt_presets: &indexmap::IndexMap<String, String>,
525    requested: &str,
526) -> Option<String> {
527    let requested = requested.trim();
528    if prompt_presets.contains_key(requested) {
529        return Some(requested.to_string());
530    }
531    if !requested.starts_with('#') {
532        let hashtag_key = format!("#{requested}");
533        if prompt_presets.contains_key(hashtag_key.as_str()) {
534            return Some(hashtag_key);
535        }
536    }
537    None
538}
539
540impl Frontmatter {
541    /// Resolve the canonical (format, write) pair from all three fields.
542    ///
543    /// Priority:
544    /// 1. Explicit `agent_doc_format` / `agent_doc_write` fields (highest)
545    /// 2. Deprecated `agent_doc_mode` field (auto-migrated)
546    /// 3. Defaults: format=template, write=crdt
547    pub fn resolve_mode(&self) -> ResolvedMode {
548        // Start with defaults
549        let mut format = AgentDocFormat::Template;
550        let mut write = AgentDocWrite::Crdt;
551
552        // Apply deprecated mode if present (lowest priority)
553        if let Some(ref mode_str) = self.mode {
554            match mode_str.as_str() {
555                "append" => {
556                    format = AgentDocFormat::Append;
557                    // write stays crdt (user preference: always crdt)
558                }
559                "template" => {
560                    format = AgentDocFormat::Template;
561                    // write stays crdt
562                }
563                "stream" => {
564                    format = AgentDocFormat::Template;
565                    write = AgentDocWrite::Crdt;
566                }
567                _ => {} // unknown mode, use defaults
568            }
569        }
570
571        // Override with explicit new fields (highest priority)
572        if let Some(f) = self.format {
573            format = f;
574        }
575        if let Some(w) = self.write_mode {
576            write = w;
577        }
578
579        ResolvedMode { format, write }
580    }
581
582    /// Resolve the effective model for attribution, given the active harness.
583    ///
584    /// Priority: harness-specific field (`claude_model` / `codex_model` / `opencode_model`)
585    /// over generic `model`.
586    /// Harness mapping: `"claude-code"` uses `claude_model`, `"codex"` uses `codex_model`,
587    /// and `"opencode"` uses `opencode_model`.
588    pub fn resolve_harness_model(&self, harness: &str) -> Option<&str> {
589        let harness_specific = match harness {
590            "claude-code" => self.claude_model.as_deref(),
591            "codex" => self.codex_model.as_deref(),
592            "opencode" => self.opencode_model.as_deref(),
593            _ => None,
594        };
595        harness_specific.or(self.model.as_deref())
596    }
597
598    pub fn collaboration_mode(&self) -> CollaborationMode {
599        self.collaboration.unwrap_or_default()
600    }
601
602    pub fn has_security_review(&self) -> bool {
603        self.security_review
604            .as_deref()
605            .map(str::trim)
606            .is_some_and(|value| !value.is_empty())
607    }
608}
609
610/// Parse YAML frontmatter from a document. Returns (frontmatter, body).
611/// If no frontmatter block is present, returns defaults and the full content as body.
612pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
613    let Some((yaml, body)) = split_frontmatter(content)? else {
614        return Ok((Frontmatter::default(), content));
615    };
616    let fm: Frontmatter = serde_yaml::from_str(yaml)?;
617    Ok((fm, body))
618}
619
620/// Wrap a frontmatter parse failure with the target document display string
621/// and a repair hint.
622pub fn contextualize_parse_error(file_display: &str, err: anyhow::Error) -> anyhow::Error {
623    anyhow::anyhow!(
624        "invalid YAML frontmatter in {}: {}\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
625        file_display,
626        err
627    )
628}
629
630/// Pure SSH-resolver context for [`parse_with_ssh_resolver`] / [`ensure_session_with_ssh_resolver`].
631///
632/// Callers in the orchestration layer pre-compute the project config + canonical
633/// relative path before invoking the pure parse path, so this module never has
634/// to touch `std::fs::canonicalize` or walk the project root from a `&Path`.
635pub struct SshResolverContext<'a> {
636    /// Resolved project config (already loaded from `.agent-doc/config.toml`).
637    pub project: &'a crate::project_config::ProjectConfig,
638    /// Path of the document relative to the project root, forward-slashed
639    /// (e.g. `tasks/agent-doc/plan.md`). Empty string when no project root.
640    pub doc_relative: &'a str,
641    /// Display string for the document path — used only for error messages.
642    pub file_display: &'a str,
643}
644
645/// Parse frontmatter using a pre-resolved SSH-defaults context.
646///
647/// Pure: no `&Path`, no `std::fs`. The file-based `parse_for_file` wrapper
648/// in `agent_doc::frontmatter_io` resolves the context and delegates here.
649pub fn parse_with_ssh_resolver<'a>(
650    content: &'a str,
651    ctx: &SshResolverContext<'_>,
652) -> Result<(Frontmatter, &'a str)> {
653    let Some((yaml, body)) = split_frontmatter(content)? else {
654        let mut fm = Frontmatter::default();
655        apply_required_ssh_defaults(&mut fm, ctx)?;
656        return Ok((fm, content));
657    };
658    match serde_yaml::from_str(yaml) {
659        Ok(mut fm) => {
660            apply_required_ssh_defaults(&mut fm, ctx)?;
661            Ok((fm, body))
662        }
663        Err(err) => Err(contextualize_yaml_parse_error(ctx.file_display, yaml, err)),
664    }
665}
666
667/// Ensure a session id using a pre-resolved SSH-defaults context.
668pub fn ensure_session_with_ssh_resolver(
669    content: &str,
670    ctx: &SshResolverContext<'_>,
671) -> Result<(String, String)> {
672    let (mut fm, body) = parse_with_ssh_resolver(content, ctx)?;
673    if let Some(existing) = fm.session.clone() {
674        return Ok((content.to_string(), existing));
675    }
676    let session_id = Uuid::new_v4().to_string();
677    fm.session = Some(session_id.clone());
678    let updated = write(&fm, body)?;
679    Ok((updated, session_id))
680}
681
682/// Write frontmatter back into a document, preserving the body.
683pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
684    let yaml = serde_yaml::to_string(fm)?;
685    Ok(format!("---\n{}---\n{}", yaml, body))
686}
687
688/// Update the session ID in a document string. Creates frontmatter if missing.
689pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
690    let (mut fm, body) = parse(content)?;
691    fm.session = Some(session_id.to_string());
692    write(&fm, body)
693}
694
695/// Update the resume (agent conversation) ID in a document string.
696pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
697    let (mut fm, body) = parse(content)?;
698    fm.resume = Some(resume_id.to_string());
699    write(&fm, body)
700}
701
702/// Set both agent_doc_format and agent_doc_write, clearing deprecated agent_doc_mode.
703pub fn set_format_and_write(
704    content: &str,
705    format: AgentDocFormat,
706    write_mode: AgentDocWrite,
707) -> Result<String> {
708    let (mut fm, body) = parse(content)?;
709    fm.format = Some(format);
710    fm.write_mode = Some(write_mode);
711    fm.mode = None;
712    write(&fm, body)
713}
714
715/// Merge YAML key/value pairs into a document's frontmatter.
716///
717/// Takes a YAML string of fields to merge (additive — never removes keys).
718/// Only known frontmatter fields are applied; unknown keys are ignored.
719/// Returns the updated document content.
720pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
721    let (mut fm, body) = parse(content)?;
722    let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
723        .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
724    let mapping = patch
725        .as_mapping()
726        .unwrap_or(&serde_yaml::Mapping::new())
727        .clone();
728
729    for (key, value) in &mapping {
730        let key_str = key.as_str().unwrap_or("");
731        let val_str = || value.as_str().map(|s| s.to_string());
732        match key_str {
733            "agent_doc_session" | "session" => fm.session = val_str(),
734            "resume" => fm.resume = val_str(),
735            "agent" => fm.agent = val_str(),
736            "model" => fm.model = val_str(),
737            "claude_model" => fm.claude_model = val_str(),
738            "codex_model" => fm.codex_model = val_str(),
739            "opencode_model" => fm.opencode_model = val_str(),
740            "branch" => fm.branch = val_str(),
741            "tmux_session" => fm.tmux_session = val_str(),
742            "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
743            "agent_doc_format" => {
744                if let Some(s) = value.as_str()
745                    && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
746                {
747                    fm.format = Some(f);
748                }
749            }
750            "agent_doc_write" => {
751                if let Some(s) = value.as_str()
752                    && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
753                {
754                    fm.write_mode = Some(w);
755                }
756            }
757            "pending_capture_guard" => {
758                if let Some(s) = value.as_str()
759                    && let Ok(mode) =
760                        serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
761                {
762                    fm.pending_capture_guard = Some(mode);
763                }
764            }
765            "pending_done_guard" => {
766                if let Some(s) = value.as_str()
767                    && let Ok(mode) =
768                        serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
769                {
770                    fm.pending_done_guard = Some(mode);
771                }
772            }
773            "review_done_guard" => {
774                if let Some(s) = value.as_str()
775                    && let Ok(mode) =
776                        serde_yaml::from_str::<PendingCaptureGuardMode>(&format!("\"{}\"", s))
777                {
778                    fm.review_done_guard = Some(mode);
779                }
780            }
781            "auto_done" => {
782                if let Some(enabled) = value.as_bool() {
783                    fm.auto_done = Some(enabled);
784                }
785            }
786            "prompt_presets" => {
787                if let Ok(presets) =
788                    serde_yaml::from_value::<indexmap::IndexMap<String, String>>(value.clone())
789                {
790                    fm.prompt_presets = presets;
791                }
792            }
793            "agent_args" => fm.agent_args = val_str(),
794            "claude_args" => fm.claude_args = val_str(),
795            "codex_args" => fm.codex_args = val_str(),
796            "opencode_args" => fm.opencode_args = val_str(),
797            "codex_network_access" => {
798                if let Some(s) = value.as_str()
799                    && let Ok(mode) =
800                        serde_yaml::from_str::<CodexNetworkAccess>(&format!("\"{}\"", s))
801                {
802                    fm.codex_network_access = Some(mode);
803                }
804            }
805            "required_ssh_targets" => {
806                if let Ok(targets) = serde_yaml::from_value::<Vec<String>>(value.clone()) {
807                    fm.required_ssh_targets = targets;
808                }
809            }
810            "required_ssh_profile" => fm.required_ssh_profile = val_str(),
811            "queue_active" => {
812                fm.queue_active = value.as_bool();
813            }
814            "agent_doc_collaboration" => {
815                if let Some(s) = value.as_str()
816                    && let Ok(mode) =
817                        serde_yaml::from_str::<CollaborationMode>(&format!("\"{}\"", s))
818                {
819                    fm.collaboration = Some(mode);
820                }
821            }
822            "agent_doc_security_review" => fm.security_review = val_str(),
823            _ => {
824                eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
825            }
826        }
827    }
828
829    write(&fm, body)
830}
831
832/// Update the tmux_session name in a document string.
833///
834/// **Deprecated.** `tmux_session` in frontmatter is deprecated — session is now
835/// determined at runtime. This function is retained for backward compatibility
836/// (claim and sync still write it so older binaries can read it).
837pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
838    let (mut fm, body) = parse(content)?;
839    fm.tmux_session = Some(session_name.to_string());
840    write(&fm, body)
841}
842
843/// Ensure the document has a session ID. If no frontmatter exists, creates one
844/// with a new UUID v4. If frontmatter exists but session is None/null, generates
845/// a UUID and sets it. If session already exists, returns as-is.
846/// Returns (updated_content, session_id).
847pub fn ensure_session(content: &str) -> Result<(String, String)> {
848    let (fm, _body) = parse(content)?;
849    if let Some(ref session_id) = fm.session {
850        // Session already set — return content unchanged
851        return Ok((content.to_string(), session_id.clone()));
852    }
853    let session_id = Uuid::new_v4().to_string();
854    let updated = set_session_id(content, &session_id)?;
855    Ok((updated, session_id))
856}
857
858fn split_frontmatter(content: &str) -> Result<Option<(&str, &str)>> {
859    if !content.starts_with("---\n") {
860        return Ok(None);
861    }
862    let rest = &content[4..]; // skip opening ---\n
863    let (end, closing_len) = rest
864        .find("\n---\n")
865        .map(|end| (end, 5))
866        .or_else(|| rest.find("\n---").map(|end| (end, 4)))
867        .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
868    let yaml = &rest[..end];
869    let body_start = 4 + end + closing_len; // opening ---\n + yaml + closing marker
870    let body = if body_start <= content.len() {
871        &content[body_start..]
872    } else {
873        ""
874    };
875    Ok(Some((yaml, body)))
876}
877
878/// Apply required-SSH defaults to a frontmatter using a pre-resolved context.
879///
880/// Pure: no `&Path`, no `std::fs`. The caller is responsible for resolving
881/// the project config and computing the document's project-relative path
882/// before invoking.
883pub fn apply_required_ssh_defaults(
884    fm: &mut Frontmatter,
885    ctx: &SshResolverContext<'_>,
886) -> Result<()> {
887    if !fm.required_ssh_targets.is_empty() {
888        fm.required_ssh_targets = normalize_required_ssh_targets(&fm.required_ssh_targets);
889        return Ok(());
890    }
891
892    if ctx.doc_relative.is_empty() {
893        return Ok(());
894    }
895
896    let resolved = if let Some(profile_name) = fm.required_ssh_profile.as_deref() {
897        resolve_required_ssh_profile_targets(&ctx.project.ssh, profile_name, ctx.file_display)?
898    } else if let Some(doc_cfg) = ctx.project.ssh.docs.get(ctx.doc_relative) {
899        let mut targets = doc_cfg.targets.clone();
900        if let Some(profile_name) = doc_cfg.profile.as_deref() {
901            targets.extend(resolve_required_ssh_profile_targets(
902                &ctx.project.ssh,
903                profile_name,
904                ctx.file_display,
905            )?);
906        }
907        if targets.is_empty() {
908            anyhow::bail!(
909                "document {} is configured as SSH-dependent in .agent-doc/config.toml but no required_ssh_targets resolved for `{}`.\n\nAdd direct targets under [ssh.docs.\"{}\"] or define a valid profile under [ssh.profiles.<name>].",
910                ctx.file_display,
911                ctx.doc_relative,
912                ctx.doc_relative
913            );
914        }
915        targets
916    } else {
917        Vec::new()
918    };
919
920    fm.required_ssh_targets = normalize_required_ssh_targets(&resolved);
921    Ok(())
922}
923
924fn resolve_required_ssh_profile_targets(
925    ssh: &crate::project_config::SshConfig,
926    profile_name: &str,
927    file_display: &str,
928) -> Result<Vec<String>> {
929    let profile = ssh.profiles.get(profile_name).ok_or_else(|| {
930        anyhow::anyhow!(
931            "document {} requires SSH profile `{}` but .agent-doc/config.toml has no matching [ssh.profiles.{}] entry.",
932            file_display,
933            profile_name,
934            profile_name
935        )
936    })?;
937    if profile.targets.is_empty() {
938        anyhow::bail!(
939            "document {} requires SSH profile `{}` but [ssh.profiles.{}] has no targets.",
940            file_display,
941            profile_name,
942            profile_name
943        );
944    }
945    Ok(profile.targets.clone())
946}
947
948fn normalize_required_ssh_targets(targets: &[String]) -> Vec<String> {
949    let mut normalized = Vec::new();
950    for target in targets {
951        let trimmed = target.trim();
952        if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
953            continue;
954        }
955        normalized.push(trimmed.to_string());
956    }
957    normalized
958}
959
960fn contextualize_yaml_parse_error(
961    file_display: &str,
962    yaml: &str,
963    err: serde_yaml::Error,
964) -> anyhow::Error {
965    let excerpt = err
966        .location()
967        .and_then(|location| render_frontmatter_excerpt(yaml, location.line(), location.column()));
968    let mut message = format!("invalid YAML frontmatter in {}: {}", file_display, err);
969    if let Some(excerpt) = excerpt {
970        message.push_str("\n\nFrontmatter excerpt:\n");
971        message.push_str(&excerpt);
972    }
973    message.push_str(
974        "\n\nFix the frontmatter between the opening and closing --- markers, then rerun the command.",
975    );
976    anyhow::anyhow!(message)
977}
978
979fn render_frontmatter_excerpt(yaml: &str, line: usize, column: usize) -> Option<String> {
980    let lines: Vec<&str> = yaml.lines().collect();
981    if line == 0 || line > lines.len() {
982        return None;
983    }
984
985    let start = line.saturating_sub(1).max(1);
986    let end = (line + 1).min(lines.len());
987    let width = end.to_string().len();
988    let mut rendered = Vec::new();
989
990    for current in start..=end {
991        let marker = if current == line { '>' } else { ' ' };
992        rendered.push(format!(
993            "{} {:>width$} | {}",
994            marker,
995            current,
996            lines[current - 1],
997            width = width
998        ));
999        if current == line {
1000            rendered.push(format!(
1001                "  {:>width$} | {}^",
1002                "",
1003                " ".repeat(column.saturating_sub(1)),
1004                width = width
1005            ));
1006        }
1007    }
1008
1009    Some(rendered.join("\n"))
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use tempfile::TempDir;
1016
1017    #[test]
1018    fn parse_no_frontmatter() {
1019        let content = "# Hello\n\nBody text.\n";
1020        let (fm, body) = parse(content).unwrap();
1021        assert!(fm.session.is_none());
1022        assert!(fm.agent.is_none());
1023        assert!(fm.model.is_none());
1024        assert!(fm.branch.is_none());
1025        assert_eq!(body, content);
1026    }
1027
1028    #[test]
1029    fn parse_all_fields() {
1030        let content =
1031            "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
1032        let (fm, body) = parse(content).unwrap();
1033        assert_eq!(fm.session.as_deref(), Some("abc-123"));
1034        assert_eq!(fm.agent.as_deref(), Some("claude"));
1035        assert_eq!(fm.model.as_deref(), Some("opus"));
1036        assert_eq!(fm.branch.as_deref(), Some("main"));
1037        assert!(body.contains("Body"));
1038    }
1039
1040    #[test]
1041    fn parse_partial_fields() {
1042        let content = "---\nsession: xyz\n---\n# Doc\n";
1043        let (fm, body) = parse(content).unwrap();
1044        assert_eq!(fm.session.as_deref(), Some("xyz"));
1045        assert!(fm.agent.is_none());
1046        assert!(body.contains("# Doc"));
1047    }
1048
1049
1050    #[test]
1051    fn parse_model_tier_high() {
1052        let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
1053        let (fm, _) = parse(content).unwrap();
1054        assert_eq!(fm.model_tier, Some(Tier::High));
1055    }
1056
1057    #[test]
1058    fn parse_collaboration_mode_shared() {
1059        let content = "---\nagent_doc_collaboration: shared\n---\nBody\n";
1060        let (fm, _) = parse(content).unwrap();
1061        assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1062    }
1063
1064    #[test]
1065    fn parse_security_review_roundtrip() {
1066        let content = "---\nagent_doc_collaboration: shared\nagent_doc_security_review: sec-2026-04-29\n---\nBody\n";
1067        let (fm, _) = parse(content).unwrap();
1068        assert_eq!(fm.collaboration_mode(), CollaborationMode::Shared);
1069        assert_eq!(fm.security_review.as_deref(), Some("sec-2026-04-29"));
1070        assert!(fm.has_security_review());
1071        let written = write(&fm, "Body\n").unwrap();
1072        assert!(written.contains("agent_doc_collaboration: shared"));
1073        assert!(written.contains("agent_doc_security_review: sec-2026-04-29"));
1074    }
1075
1076    #[test]
1077    fn parse_model_tier_low() {
1078        let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
1079        let (fm, _) = parse(content).unwrap();
1080        assert_eq!(fm.model_tier, Some(Tier::Low));
1081    }
1082
1083    #[test]
1084    fn parse_model_tier_med() {
1085        let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
1086        let (fm, _) = parse(content).unwrap();
1087        assert_eq!(fm.model_tier, Some(Tier::Med));
1088    }
1089
1090    #[test]
1091    fn parse_model_tier_auto() {
1092        let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
1093        let (fm, _) = parse(content).unwrap();
1094        assert_eq!(fm.model_tier, Some(Tier::Auto));
1095    }
1096
1097    #[test]
1098    fn parse_model_tier_absent() {
1099        let content = "---\nagent: claude\n---\nBody\n";
1100        let (fm, _) = parse(content).unwrap();
1101        assert_eq!(fm.model_tier, None);
1102    }
1103
1104    #[test]
1105    fn parse_pending_capture_guard_strict() {
1106        let content = "---\npending_capture_guard: strict\n---\nBody\n";
1107        let (fm, _) = parse(content).unwrap();
1108        assert_eq!(
1109            fm.pending_capture_guard,
1110            Some(PendingCaptureGuardMode::Strict)
1111        );
1112    }
1113
1114    #[test]
1115    fn parse_pending_capture_guard_invalid_rejected() {
1116        let content = "---\npending_capture_guard: loudly\n---\nBody\n";
1117        assert!(parse(content).is_err());
1118    }
1119
1120    #[test]
1121    fn parse_pending_done_guard_strict() {
1122        let content = "---\npending_done_guard: strict\n---\nBody\n";
1123        let (fm, _) = parse(content).unwrap();
1124        assert_eq!(fm.pending_done_guard, Some(PendingCaptureGuardMode::Strict));
1125    }
1126
1127    #[test]
1128    fn parse_pending_done_guard_invalid_rejected() {
1129        let content = "---\npending_done_guard: loudly\n---\nBody\n";
1130        assert!(parse(content).is_err());
1131    }
1132
1133    #[test]
1134    fn parse_model_tier_invalid_rejected() {
1135        let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
1136        let result = parse(content);
1137        assert!(result.is_err(), "invalid tier value should fail to parse");
1138    }
1139
1140    #[test]
1141    fn write_model_tier_roundtrip() {
1142        let fm = Frontmatter {
1143            model_tier: Some(Tier::High),
1144            ..Default::default()
1145        };
1146        let doc = write(&fm, "Body\n").unwrap();
1147        let (parsed, _) = parse(&doc).unwrap();
1148        assert_eq!(parsed.model_tier, Some(Tier::High));
1149        assert!(doc.contains("agent_doc_model_tier: high"));
1150    }
1151
1152    #[test]
1153    fn parse_null_fields() {
1154        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
1155        let (fm, body) = parse(content).unwrap();
1156        assert!(fm.session.is_none());
1157        assert!(fm.agent.is_none());
1158        assert!(fm.model.is_none());
1159        assert!(fm.branch.is_none());
1160        assert!(body.contains("Body"));
1161    }
1162
1163    #[test]
1164    fn parse_unterminated_frontmatter() {
1165        let content = "---\nsession: abc\nno closing block";
1166        let err = parse(content).unwrap_err();
1167        assert!(err.to_string().contains("Unterminated frontmatter"));
1168    }
1169
1170    #[test]
1171    fn parse_closing_at_eof() {
1172        let content = "---\nsession: abc\n---";
1173        let (fm, body) = parse(content).unwrap();
1174        assert_eq!(fm.session.as_deref(), Some("abc"));
1175        assert_eq!(body, "");
1176    }
1177
1178    #[test]
1179    fn parse_empty_body() {
1180        let content = "---\nsession: abc\n---\n";
1181        let (fm, _body) = parse(content).unwrap();
1182        assert_eq!(fm.session.as_deref(), Some("abc"));
1183    }
1184
1185    #[test]
1186    fn write_roundtrip() {
1187        // Start from write output to ensure consistent formatting
1188        let fm = Frontmatter {
1189            session: Some("test-id".to_string()),
1190            resume: Some("resume-id".to_string()),
1191            agent: Some("claude".to_string()),
1192            model: Some("opus".to_string()),
1193            claude_model: None,
1194            codex_model: None,
1195            opencode_model: None,
1196            branch: Some("dev".to_string()),
1197            tmux_session: None,
1198            mode: None,
1199            format: None,
1200            write_mode: None,
1201            stream_config: None,
1202            agent_args: None,
1203            claude_args: None,
1204            codex_args: None,
1205            opencode_args: None,
1206            codex_network_access: None,
1207            required_ssh_targets: Vec::new(),
1208            required_ssh_profile: None,
1209            no_mcp: None,
1210            enable_tool_search: None,
1211            debounce_ms: None,
1212            links: vec![],
1213            auto_compact: None,
1214            model_tier: None,
1215            pending_capture_guard: None,
1216            pending_done_guard: None,
1217            review_done_guard: None,
1218            auto_done: None,
1219            agent_doc_lint_dialect: None,
1220            hooks: std::collections::HashMap::new(),
1221            env: indexmap::IndexMap::new(),
1222            prompt_presets: indexmap::IndexMap::new(),
1223            dispatch: None,
1224            agent_doc_env_inherit: None,
1225            cwd: None,
1226            queue_active: None,
1227            collaboration: None,
1228            security_review: None,
1229        };
1230        let body = "# Hello\n\nBody text.\n";
1231        let written = write(&fm, body).unwrap();
1232        let (fm2, body2) = parse(&written).unwrap();
1233        assert_eq!(fm2.session, fm.session);
1234        assert_eq!(fm2.agent, fm.agent);
1235        assert_eq!(fm2.model, fm.model);
1236        assert_eq!(fm2.claude_model, fm.claude_model);
1237        assert_eq!(fm2.codex_model, fm.codex_model);
1238        assert_eq!(fm2.opencode_model, fm.opencode_model);
1239        assert_eq!(fm2.branch, fm.branch);
1240        assert_eq!(fm2.collaboration, fm.collaboration);
1241        assert_eq!(fm2.security_review, fm.security_review);
1242        assert_eq!(body2, body);
1243    }
1244
1245    #[test]
1246    fn repeated_parse_write_roundtrip_preserves_body_prefix() {
1247        let fm = Frontmatter {
1248            session: Some("abc".to_string()),
1249            agent: Some("codex".to_string()),
1250            ..Default::default()
1251        };
1252        let original = write(&fm, "## Status\n\nBody\n").unwrap();
1253        let mut current = original.clone();
1254
1255        for _ in 0..3 {
1256            let (fm, body) = parse(&current).unwrap();
1257            assert_eq!(body, "## Status\n\nBody\n");
1258            current = write(&fm, body).unwrap();
1259        }
1260
1261        let (fm, body) = parse(&current).unwrap();
1262        assert_eq!(fm.session.as_deref(), Some("abc"));
1263        assert_eq!(fm.agent.as_deref(), Some("codex"));
1264        assert_eq!(body, "## Status\n\nBody\n");
1265        assert_eq!(current, original);
1266    }
1267
1268    #[test]
1269    fn write_default_frontmatter() {
1270        let fm = Frontmatter::default();
1271        let result = write(&fm, "body\n").unwrap();
1272        assert!(result.starts_with("---\n"));
1273        assert!(result.ends_with("---\nbody\n"));
1274    }
1275
1276    #[test]
1277    fn write_preserves_body_content() {
1278        let fm = Frontmatter::default();
1279        let body = "# Title\n\nSome **markdown** with `code`.\n";
1280        let result = write(&fm, body).unwrap();
1281        assert!(result.contains("# Title"));
1282        assert!(result.contains("Some **markdown** with `code`."));
1283    }
1284
1285    #[test]
1286    fn set_session_id_creates_frontmatter() {
1287        let content = "# No frontmatter\n\nJust body.\n";
1288        let result = set_session_id(content, "new-session").unwrap();
1289        let (fm, body) = parse(&result).unwrap();
1290        assert_eq!(fm.session.as_deref(), Some("new-session"));
1291        assert!(body.contains("# No frontmatter"));
1292    }
1293
1294    #[test]
1295    fn set_session_id_updates_existing() {
1296        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
1297        let result = set_session_id(content, "new-id").unwrap();
1298        let (fm, body) = parse(&result).unwrap();
1299        assert_eq!(fm.session.as_deref(), Some("new-id"));
1300        assert_eq!(fm.agent.as_deref(), Some("claude"));
1301        assert!(body.contains("Body"));
1302    }
1303
1304    #[test]
1305    fn set_session_id_preserves_other_fields() {
1306        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
1307        let result = set_session_id(content, "new").unwrap();
1308        let (fm, _) = parse(&result).unwrap();
1309        assert_eq!(fm.session.as_deref(), Some("new"));
1310        assert_eq!(fm.agent.as_deref(), Some("claude"));
1311        assert_eq!(fm.model.as_deref(), Some("opus"));
1312        assert_eq!(fm.branch.as_deref(), Some("dev"));
1313    }
1314
1315    #[test]
1316    fn ensure_session_no_frontmatter() {
1317        let content = "# Hello\n\nBody.\n";
1318        let (updated, sid) = ensure_session(content).unwrap();
1319        // Should have generated a UUID
1320        assert_eq!(sid.len(), 36); // UUID v4 string length
1321        let (fm, body) = parse(&updated).unwrap();
1322        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
1323        assert!(body.contains("# Hello"));
1324    }
1325
1326    #[test]
1327    fn ensure_session_null_session() {
1328        let content = "---\nsession:\nagent: claude\n---\nBody\n";
1329        let (updated, sid) = ensure_session(content).unwrap();
1330        assert_eq!(sid.len(), 36);
1331        let (fm, body) = parse(&updated).unwrap();
1332        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
1333        assert_eq!(fm.agent.as_deref(), Some("claude"));
1334        assert!(body.contains("Body"));
1335    }
1336
1337    #[test]
1338    fn ensure_session_existing_session() {
1339        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
1340        let (updated, sid) = ensure_session(content).unwrap();
1341        assert_eq!(sid, "existing-id");
1342        // Content should be unchanged
1343        assert_eq!(updated, content);
1344    }
1345
1346    #[test]
1347    fn parse_legacy_session_field() {
1348        // Old `session:` field should still parse via serde alias
1349        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
1350        let (fm, body) = parse(content).unwrap();
1351        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
1352        assert_eq!(fm.agent.as_deref(), Some("claude"));
1353        assert!(body.contains("Body"));
1354    }
1355
1356    #[test]
1357    fn parse_agent_doc_mode_canonical() {
1358        let content = "---\nagent_doc_mode: template\n---\nBody\n";
1359        let (fm, _) = parse(content).unwrap();
1360        assert_eq!(fm.mode.as_deref(), Some("template"));
1361    }
1362
1363    #[test]
1364    fn parse_mode_shorthand_alias() {
1365        let content = "---\nmode: template\n---\nBody\n";
1366        let (fm, _) = parse(content).unwrap();
1367        assert_eq!(fm.mode.as_deref(), Some("template"));
1368    }
1369
1370    #[test]
1371    fn parse_response_mode_legacy_alias() {
1372        let content = "---\nresponse_mode: template\n---\nBody\n";
1373        let (fm, _) = parse(content).unwrap();
1374        assert_eq!(fm.mode.as_deref(), Some("template"));
1375    }
1376
1377    #[test]
1378    fn write_uses_agent_doc_mode_field() {
1379        #[allow(deprecated)]
1380        let fm = Frontmatter {
1381            mode: Some("template".to_string()),
1382            ..Default::default()
1383        };
1384        let result = write(&fm, "body\n").unwrap();
1385        assert!(result.contains("agent_doc_mode:"));
1386        assert!(!result.contains("response_mode:"));
1387        assert!(!result.contains("\nmode:"));
1388    }
1389
1390    #[test]
1391    fn write_uses_new_field_name() {
1392        let fm = Frontmatter {
1393            session: Some("test-id".to_string()),
1394            ..Default::default()
1395        };
1396        let result = write(&fm, "body\n").unwrap();
1397        assert!(result.contains("agent_doc_session:"));
1398        assert!(!result.contains("\nsession:"));
1399    }
1400
1401    #[test]
1402    fn write_pending_capture_guard_roundtrip() {
1403        let fm = Frontmatter {
1404            pending_capture_guard: Some(PendingCaptureGuardMode::Strict),
1405            ..Default::default()
1406        };
1407        let written = write(&fm, "body\n").unwrap();
1408        assert!(written.contains("pending_capture_guard: strict"));
1409        let (parsed, _) = parse(&written).unwrap();
1410        assert_eq!(
1411            parsed.pending_capture_guard,
1412            Some(PendingCaptureGuardMode::Strict)
1413        );
1414    }
1415
1416    #[test]
1417    fn write_pending_done_guard_roundtrip() {
1418        let fm = Frontmatter {
1419            pending_done_guard: Some(PendingCaptureGuardMode::Strict),
1420            ..Default::default()
1421        };
1422        let written = write(&fm, "body\n").unwrap();
1423        assert!(written.contains("pending_done_guard: strict"));
1424        let (parsed, _) = parse(&written).unwrap();
1425        assert_eq!(
1426            parsed.pending_done_guard,
1427            Some(PendingCaptureGuardMode::Strict)
1428        );
1429    }
1430
1431    #[test]
1432    fn parse_review_done_guard_error_alias() {
1433        let content = "---\nreview_done_guard: error\n---\nBody\n";
1434        let (fm, _) = parse(content).unwrap();
1435        assert_eq!(fm.review_done_guard, Some(PendingCaptureGuardMode::Strict));
1436    }
1437
1438    #[test]
1439    fn write_review_done_guard_roundtrip() {
1440        let fm = Frontmatter {
1441            review_done_guard: Some(PendingCaptureGuardMode::Strict),
1442            ..Default::default()
1443        };
1444        let written = write(&fm, "body\n").unwrap();
1445        assert!(written.contains("review_done_guard: strict"));
1446        let (parsed, _) = parse(&written).unwrap();
1447        assert_eq!(
1448            parsed.review_done_guard,
1449            Some(PendingCaptureGuardMode::Strict)
1450        );
1451    }
1452
1453    #[test]
1454    fn parse_auto_done_bool() {
1455        let content = "---\nauto_done: true\n---\nBody\n";
1456        let (fm, _) = parse(content).unwrap();
1457        assert_eq!(fm.auto_done, Some(true));
1458    }
1459
1460    #[test]
1461    fn write_auto_done_roundtrip() {
1462        let fm = Frontmatter {
1463            auto_done: Some(true),
1464            ..Default::default()
1465        };
1466        let written = write(&fm, "Body\n").unwrap();
1467        assert!(written.contains("auto_done: true"));
1468        let (parsed, _) = parse(&written).unwrap();
1469        assert_eq!(parsed.auto_done, Some(true));
1470    }
1471
1472    #[test]
1473    fn parse_prompt_presets_roundtrip() {
1474        let content = "---\nprompt_presets:\n  \"#1\": |\n    Today is 2026-04-25.\n    Keep the work tree clean.\n  release-check: |\n    Run cargo test.\n---\nBody\n";
1475        let (fm, body) = parse(content).unwrap();
1476        assert_eq!(body, "Body\n");
1477        assert_eq!(
1478            fm.prompt_presets.get("#1").map(String::as_str),
1479            Some("Today is 2026-04-25.\nKeep the work tree clean.\n")
1480        );
1481        assert_eq!(
1482            fm.prompt_presets.get("release-check").map(String::as_str),
1483            Some("Run cargo test.")
1484        );
1485
1486        let written = write(&fm, body).unwrap();
1487        let (parsed, body2) = parse(&written).unwrap();
1488        assert_eq!(body2, "Body\n");
1489        assert_eq!(parsed.prompt_presets, fm.prompt_presets);
1490    }
1491
1492    #[test]
1493    fn resolve_prompt_preset_key_accepts_bare_hashtag_alias() {
1494        let mut prompt_presets = indexmap::IndexMap::new();
1495        prompt_presets.insert("#spec-test".to_string(), "Run checks.".to_string());
1496        prompt_presets.insert("release-check".to_string(), "Publish.".to_string());
1497
1498        assert_eq!(
1499            resolve_prompt_preset_key(&prompt_presets, "spec-test").as_deref(),
1500            Some("#spec-test")
1501        );
1502        assert_eq!(
1503            resolve_prompt_preset_key(&prompt_presets, "#spec-test").as_deref(),
1504            Some("#spec-test")
1505        );
1506        assert_eq!(
1507            resolve_prompt_preset_key(&prompt_presets, "release-check").as_deref(),
1508            Some("release-check")
1509        );
1510        assert!(resolve_prompt_preset_key(&prompt_presets, "missing").is_none());
1511    }
1512
1513    // --- resolve_mode tests ---
1514
1515    #[test]
1516    fn resolve_mode_defaults() {
1517        let fm = Frontmatter::default();
1518        let resolved = fm.resolve_mode();
1519        assert_eq!(resolved.format, AgentDocFormat::Template);
1520        assert_eq!(resolved.write, AgentDocWrite::Crdt);
1521    }
1522
1523    #[test]
1524    fn resolve_mode_from_deprecated_append() {
1525        let content = "---\nagent_doc_mode: append\n---\nBody\n";
1526        let (fm, _) = parse(content).unwrap();
1527        let resolved = fm.resolve_mode();
1528        assert_eq!(resolved.format, AgentDocFormat::Append);
1529        assert_eq!(resolved.write, AgentDocWrite::Crdt);
1530    }
1531
1532    #[test]
1533    fn resolve_mode_from_deprecated_template() {
1534        let content = "---\nagent_doc_mode: template\n---\nBody\n";
1535        let (fm, _) = parse(content).unwrap();
1536        let resolved = fm.resolve_mode();
1537        assert_eq!(resolved.format, AgentDocFormat::Template);
1538        assert_eq!(resolved.write, AgentDocWrite::Crdt);
1539    }
1540
1541    #[test]
1542    fn resolve_mode_from_deprecated_stream() {
1543        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
1544        let (fm, _) = parse(content).unwrap();
1545        let resolved = fm.resolve_mode();
1546        assert_eq!(resolved.format, AgentDocFormat::Template);
1547        assert_eq!(resolved.write, AgentDocWrite::Crdt);
1548    }
1549
1550    #[test]
1551    fn resolve_mode_new_fields_override_deprecated() {
1552        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
1553        let (fm, _) = parse(content).unwrap();
1554        let resolved = fm.resolve_mode();
1555        assert_eq!(resolved.format, AgentDocFormat::Template);
1556        assert_eq!(resolved.write, AgentDocWrite::Merge);
1557    }
1558
1559    #[test]
1560    fn resolve_mode_explicit_new_fields_only() {
1561        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
1562        let (fm, _) = parse(content).unwrap();
1563        let resolved = fm.resolve_mode();
1564        assert_eq!(resolved.format, AgentDocFormat::Append);
1565        assert_eq!(resolved.write, AgentDocWrite::Crdt);
1566    }
1567
1568    #[test]
1569    fn resolve_mode_partial_new_field_format_only() {
1570        let content = "---\nagent_doc_format: append\n---\nBody\n";
1571        let (fm, _) = parse(content).unwrap();
1572        let resolved = fm.resolve_mode();
1573        assert_eq!(resolved.format, AgentDocFormat::Append);
1574        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
1575    }
1576
1577    #[test]
1578    fn resolve_mode_partial_new_field_write_only() {
1579        let content = "---\nagent_doc_write: merge\n---\nBody\n";
1580        let (fm, _) = parse(content).unwrap();
1581        let resolved = fm.resolve_mode();
1582        assert_eq!(resolved.format, AgentDocFormat::Template); // default
1583        assert_eq!(resolved.write, AgentDocWrite::Merge);
1584    }
1585
1586    #[test]
1587    fn resolve_mode_helper_methods() {
1588        let fm = Frontmatter::default();
1589        let resolved = fm.resolve_mode();
1590        assert!(resolved.is_template());
1591        assert!(!resolved.is_append());
1592        assert!(resolved.is_crdt());
1593    }
1594
1595    #[test]
1596    fn parse_new_format_field() {
1597        let content = "---\nagent_doc_format: template\n---\nBody\n";
1598        let (fm, _) = parse(content).unwrap();
1599        assert_eq!(fm.format, Some(AgentDocFormat::Template));
1600    }
1601
1602    #[test]
1603    fn parse_new_write_field() {
1604        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
1605        let (fm, _) = parse(content).unwrap();
1606        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1607    }
1608
1609    #[test]
1610    fn write_uses_new_format_write_fields() {
1611        let fm = Frontmatter {
1612            format: Some(AgentDocFormat::Template),
1613            write_mode: Some(AgentDocWrite::Crdt),
1614            ..Default::default()
1615        };
1616        let result = write(&fm, "body\n").unwrap();
1617        assert!(result.contains("agent_doc_format:"));
1618        assert!(result.contains("agent_doc_write:"));
1619        assert!(!result.contains("agent_doc_mode:"));
1620    }
1621
1622    #[test]
1623    fn set_format_and_write_clears_deprecated_mode() {
1624        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
1625        let result =
1626            set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
1627        let (fm, _) = parse(&result).unwrap();
1628        assert!(fm.mode.is_none());
1629        assert_eq!(fm.format, Some(AgentDocFormat::Template));
1630        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1631    }
1632
1633    // --- merge_fields tests ---
1634
1635    #[test]
1636    fn merge_fields_adds_new_field() {
1637        let content = "---\nagent_doc_session: abc\n---\nBody\n";
1638        let result = merge_fields(content, "model: opus").unwrap();
1639        let (fm, body) = parse(&result).unwrap();
1640        assert_eq!(fm.session.as_deref(), Some("abc"));
1641        assert_eq!(fm.model.as_deref(), Some("opus"));
1642        assert!(body.contains("Body"));
1643    }
1644
1645    #[test]
1646    fn merge_fields_updates_existing_field() {
1647        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
1648        let result = merge_fields(content, "model: opus").unwrap();
1649        let (fm, _) = parse(&result).unwrap();
1650        assert_eq!(fm.model.as_deref(), Some("opus"));
1651        assert_eq!(fm.session.as_deref(), Some("abc"));
1652    }
1653
1654    #[test]
1655    fn merge_fields_multiple_fields() {
1656        let content = "---\nagent_doc_session: abc\n---\nBody\n";
1657        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
1658        let (fm, _) = parse(&result).unwrap();
1659        assert_eq!(fm.model.as_deref(), Some("opus"));
1660        assert_eq!(fm.agent.as_deref(), Some("claude"));
1661        assert_eq!(fm.branch.as_deref(), Some("main"));
1662    }
1663
1664    #[test]
1665    fn merge_fields_format_enum() {
1666        let content = "---\nagent_doc_session: abc\n---\nBody\n";
1667        let result = merge_fields(content, "agent_doc_format: append").unwrap();
1668        let (fm, _) = parse(&result).unwrap();
1669        assert_eq!(fm.format, Some(AgentDocFormat::Append));
1670    }
1671
1672    #[test]
1673    fn merge_fields_write_enum() {
1674        let content = "---\nagent_doc_session: abc\n---\nBody\n";
1675        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
1676        let (fm, _) = parse(&result).unwrap();
1677        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
1678    }
1679
1680    #[test]
1681    fn merge_fields_ignores_unknown() {
1682        let content = "---\nagent_doc_session: abc\n---\nBody\n";
1683        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
1684        let (fm, _) = parse(&result).unwrap();
1685        assert_eq!(fm.model.as_deref(), Some("opus"));
1686    }
1687
1688    #[test]
1689    fn merge_fields_preserves_body() {
1690        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
1691        let result = merge_fields(content, "model: opus").unwrap();
1692        assert!(result.contains("# Title"));
1693        assert!(result.contains("Some **markdown** content."));
1694    }
1695
1696    #[test]
1697    fn set_format_and_write_clears_deprecated() {
1698        let content = "---\nagent_doc_mode: append\n---\nBody\n";
1699        let result =
1700            set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
1701        let (fm, _) = parse(&result).unwrap();
1702        assert!(fm.mode.is_none());
1703        assert_eq!(fm.format, Some(AgentDocFormat::Template));
1704        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1705    }
1706
1707    #[test]
1708    fn hooks_roundtrip() {
1709        let content = "---\nhooks:\n  session_start:\n    - \"echo start {{session_id}}\"\n  post_write:\n    - \"notify {{file}}\"\n---\nBody\n";
1710        let (fm, _) = parse(content).unwrap();
1711        assert_eq!(
1712            fm.hooks.get("session_start"),
1713            Some(&vec!["echo start {{session_id}}".to_string()])
1714        );
1715        assert_eq!(
1716            fm.hooks.get("post_write"),
1717            Some(&vec!["notify {{file}}".to_string()])
1718        );
1719    }
1720
1721    #[test]
1722    fn hooks_omitted_when_empty() {
1723        let fm = Frontmatter::default();
1724        let result = write(&fm, "body\n").unwrap();
1725        assert!(!result.contains("hooks"));
1726    }
1727
1728    #[test]
1729    fn hooks_absent_parses_as_empty() {
1730        let content = "---\nsession: abc\n---\nBody\n";
1731        let (fm, _) = parse(content).unwrap();
1732        assert!(fm.hooks.is_empty());
1733    }
1734
1735    #[test]
1736    fn parse_no_mcp_field() {
1737        let content = "---\nno_mcp: true\n---\nBody\n";
1738        let (fm, _) = parse(content).unwrap();
1739        assert_eq!(fm.no_mcp, Some(true));
1740    }
1741
1742    #[test]
1743    fn parse_enable_tool_search_field() {
1744        let content = "---\nenable_tool_search: true\n---\nBody\n";
1745        let (fm, _) = parse(content).unwrap();
1746        assert_eq!(fm.enable_tool_search, Some(true));
1747    }
1748
1749    #[test]
1750    fn parse_missing_flags_default_none() {
1751        let content = "---\nsession: abc\n---\nBody\n";
1752        let (fm, _) = parse(content).unwrap();
1753        assert!(fm.no_mcp.is_none());
1754        assert!(fm.enable_tool_search.is_none());
1755    }
1756
1757    #[test]
1758    fn parse_env_map() {
1759        let content = "---\nenv:\n  FOO: bar\n  BAZ: \"$(echo hello)\"\n---\nBody\n";
1760        let (fm, _) = parse(content).unwrap();
1761        assert_eq!(fm.env.len(), 2);
1762        assert_eq!(fm.env["FOO"], Some("bar".to_string()));
1763        assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
1764        // Verify order is preserved
1765        let keys: Vec<&String> = fm.env.keys().collect();
1766        assert_eq!(keys, vec!["FOO", "BAZ"]);
1767    }
1768
1769    #[test]
1770    fn parse_env_empty_default() {
1771        let content = "---\nsession: abc\n---\nBody\n";
1772        let (fm, _) = parse(content).unwrap();
1773        assert!(fm.env.is_empty());
1774    }
1775
1776    #[test]
1777    fn parse_env_unset_via_null() {
1778        let content = "---\nenv:\n  SET_ME: value\n  UNSET_ME: null\n---\nBody\n";
1779        let (fm, _) = parse(content).unwrap();
1780        assert_eq!(fm.env.len(), 2);
1781        assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
1782        assert_eq!(fm.env["UNSET_ME"], None);
1783    }
1784
1785    #[test]
1786    fn write_roundtrip_with_env() {
1787        let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
1788        env.insert("KEY1".to_string(), Some("value1".to_string()));
1789        env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
1790        env.insert("KEY3".to_string(), None);
1791        let fm = Frontmatter {
1792            env,
1793            ..Default::default()
1794        };
1795        let written = write(&fm, "body\n").unwrap();
1796        let (fm2, _) = parse(&written).unwrap();
1797        assert_eq!(fm2.env.len(), 3);
1798        assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
1799        assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
1800        assert_eq!(fm2.env["KEY3"], None);
1801    }
1802
1803    #[test]
1804    fn parse_agent_args_field() {
1805        let content = "---\nagent_args: \"--json -s workspace-write\"\n---\nBody\n";
1806        let (fm, _) = parse(content).unwrap();
1807        assert_eq!(fm.agent_args.as_deref(), Some("--json -s workspace-write"));
1808        assert!(fm.claude_args.is_none());
1809        assert!(fm.codex_args.is_none());
1810        assert!(fm.opencode_args.is_none());
1811    }
1812
1813    #[test]
1814    fn parse_agent_args_and_harness_aliases() {
1815        let content = "---\nagent_args: \"--json\"\nclaude_args: \"--dangerously-skip-permissions\"\ncodex_args: \"-s danger-full-access\"\nopencode_args: \"--dangerously-skip-permissions\"\ncodex_network_access: enabled\nrequired_ssh_targets:\n  - monsterrodholders-server\n---\nBody\n";
1816        let (fm, _) = parse(content).unwrap();
1817        assert_eq!(fm.agent_args.as_deref(), Some("--json"));
1818        assert_eq!(
1819            fm.claude_args.as_deref(),
1820            Some("--dangerously-skip-permissions")
1821        );
1822        assert_eq!(fm.codex_args.as_deref(), Some("-s danger-full-access"));
1823        assert_eq!(
1824            fm.opencode_args.as_deref(),
1825            Some("--dangerously-skip-permissions")
1826        );
1827        assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Enabled));
1828        assert_eq!(
1829            fm.required_ssh_targets,
1830            vec!["monsterrodholders-server".to_string()]
1831        );
1832    }
1833
1834    #[test]
1835    fn write_roundtrip_agent_args() {
1836        let fm = Frontmatter {
1837            agent_args: Some("--json -s workspace-write".to_string()),
1838            codex_args: Some("-s danger-full-access".to_string()),
1839            opencode_args: Some("--dangerously-skip-permissions".to_string()),
1840            codex_network_access: Some(CodexNetworkAccess::Enabled),
1841            required_ssh_targets: vec!["monsterrodholders-server".to_string()],
1842            ..Default::default()
1843        };
1844        let written = write(&fm, "body\n").unwrap();
1845        let (fm2, _) = parse(&written).unwrap();
1846        assert_eq!(fm2.agent_args.as_deref(), Some("--json -s workspace-write"));
1847        assert_eq!(fm2.codex_args.as_deref(), Some("-s danger-full-access"));
1848        assert_eq!(
1849            fm2.opencode_args.as_deref(),
1850            Some("--dangerously-skip-permissions")
1851        );
1852        assert_eq!(fm2.codex_network_access, Some(CodexNetworkAccess::Enabled));
1853        assert_eq!(
1854            fm2.required_ssh_targets,
1855            vec!["monsterrodholders-server".to_string()]
1856        );
1857    }
1858
1859    #[test]
1860    fn merge_fields_agent_args() {
1861        let content = "---\nclaude_args: old\ncodex_args: old-codex\nopencode_args: old-opencode\n---\nBody\n";
1862        let result = merge_fields(content, "agent_args: new").unwrap();
1863        let (fm, _) = parse(&result).unwrap();
1864        assert_eq!(fm.agent_args.as_deref(), Some("new"));
1865        assert_eq!(fm.claude_args.as_deref(), Some("old"));
1866        assert_eq!(fm.codex_args.as_deref(), Some("old-codex"));
1867        assert_eq!(fm.opencode_args.as_deref(), Some("old-opencode"));
1868    }
1869
1870    #[test]
1871    fn merge_fields_codex_args() {
1872        let content = "---\nagent_args: old\n---\nBody\n";
1873        let result = merge_fields(content, "codex_args: new").unwrap();
1874        let (fm, _) = parse(&result).unwrap();
1875        assert_eq!(fm.agent_args.as_deref(), Some("old"));
1876        assert_eq!(fm.codex_args.as_deref(), Some("new"));
1877    }
1878
1879    #[test]
1880    fn merge_fields_opencode_args() {
1881        let content = "---\nagent_args: old\n---\nBody\n";
1882        let result = merge_fields(content, "opencode_args: new").unwrap();
1883        let (fm, _) = parse(&result).unwrap();
1884        assert_eq!(fm.agent_args.as_deref(), Some("old"));
1885        assert_eq!(fm.opencode_args.as_deref(), Some("new"));
1886    }
1887
1888    #[test]
1889    fn merge_fields_codex_network_access() {
1890        let content = "Body\n";
1891        let result = merge_fields(content, "codex_network_access: disabled").unwrap();
1892        let (fm, _) = parse(&result).unwrap();
1893        assert_eq!(fm.codex_network_access, Some(CodexNetworkAccess::Disabled));
1894    }
1895
1896    #[test]
1897    fn merge_fields_required_ssh_targets() {
1898        let content = "Body\n";
1899        let result = merge_fields(
1900            content,
1901            "required_ssh_targets:\n  - monsterrodholders-server\n  - root@50.28.2.199",
1902        )
1903        .unwrap();
1904        let (fm, _) = parse(&result).unwrap();
1905        assert_eq!(
1906            fm.required_ssh_targets,
1907            vec![
1908                "monsterrodholders-server".to_string(),
1909                "root@50.28.2.199".to_string(),
1910            ]
1911        );
1912    }
1913
1914    #[test]
1915    fn merge_fields_required_ssh_profile() {
1916        let content = "Body\n";
1917        let result = merge_fields(content, "required_ssh_profile: monsterrodholders").unwrap();
1918        let (fm, _) = parse(&result).unwrap();
1919        assert_eq!(
1920            fm.required_ssh_profile.as_deref(),
1921            Some("monsterrodholders")
1922        );
1923    }
1924
1925    // --- resolve_harness_model tests ---
1926
1927    #[test]
1928    fn resolve_harness_model_claude_code_uses_claude_model() {
1929        let fm = Frontmatter {
1930            model: Some("gpt-5".to_string()),
1931            claude_model: Some("opus".to_string()),
1932            codex_model: Some("o3-pro".to_string()),
1933            ..Default::default()
1934        };
1935        assert_eq!(fm.resolve_harness_model("claude-code"), Some("opus"));
1936    }
1937
1938    #[test]
1939    fn resolve_harness_model_codex_uses_codex_model() {
1940        let fm = Frontmatter {
1941            model: Some("gpt-5".to_string()),
1942            claude_model: Some("opus".to_string()),
1943            codex_model: Some("o3-pro".to_string()),
1944            opencode_model: Some("zai/glm-5".to_string()),
1945            ..Default::default()
1946        };
1947        assert_eq!(fm.resolve_harness_model("codex"), Some("o3-pro"));
1948    }
1949
1950    #[test]
1951    fn resolve_harness_model_opencode_uses_opencode_model() {
1952        let fm = Frontmatter {
1953            model: Some("gpt-5".to_string()),
1954            claude_model: Some("opus".to_string()),
1955            codex_model: Some("o3-pro".to_string()),
1956            opencode_model: Some("zai/glm-5".to_string()),
1957            ..Default::default()
1958        };
1959        assert_eq!(fm.resolve_harness_model("opencode"), Some("zai/glm-5"));
1960    }
1961
1962    #[test]
1963    fn resolve_harness_model_falls_back_to_generic() {
1964        let fm = Frontmatter {
1965            model: Some("gpt-5".to_string()),
1966            ..Default::default()
1967        };
1968        assert_eq!(fm.resolve_harness_model("claude-code"), Some("gpt-5"));
1969        assert_eq!(fm.resolve_harness_model("codex"), Some("gpt-5"));
1970        assert_eq!(fm.resolve_harness_model("opencode"), Some("gpt-5"));
1971        assert_eq!(fm.resolve_harness_model("default"), Some("gpt-5"));
1972    }
1973
1974    #[test]
1975    fn resolve_harness_model_none_when_no_model() {
1976        let fm = Frontmatter::default();
1977        assert_eq!(fm.resolve_harness_model("claude-code"), None);
1978    }
1979
1980    #[test]
1981    fn resolve_harness_model_unknown_harness_uses_generic() {
1982        let fm = Frontmatter {
1983            model: Some("sonnet".to_string()),
1984            claude_model: Some("opus".to_string()),
1985            ..Default::default()
1986        };
1987        assert_eq!(fm.resolve_harness_model("unknown"), Some("sonnet"));
1988    }
1989
1990    #[test]
1991    fn parse_harness_model_fields() {
1992        let content = "---\nmodel: gpt-5\nclaude_model: opus\ncodex_model: o3-pro\nopencode_model: zai/glm-5\n---\nBody\n";
1993        let (fm, _) = parse(content).unwrap();
1994        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
1995        assert_eq!(fm.claude_model.as_deref(), Some("opus"));
1996        assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
1997        assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
1998    }
1999
2000    #[test]
2001    fn write_roundtrip_harness_model_fields() {
2002        let fm = Frontmatter {
2003            model: Some("gpt-5".to_string()),
2004            claude_model: Some("opus".to_string()),
2005            codex_model: Some("o3-pro".to_string()),
2006            opencode_model: Some("zai/glm-5".to_string()),
2007            ..Default::default()
2008        };
2009        let doc = write(&fm, "Body\n").unwrap();
2010        let (fm2, body) = parse(&doc).unwrap();
2011        assert_eq!(fm2.model.as_deref(), Some("gpt-5"));
2012        assert_eq!(fm2.claude_model.as_deref(), Some("opus"));
2013        assert_eq!(fm2.codex_model.as_deref(), Some("o3-pro"));
2014        assert_eq!(fm2.opencode_model.as_deref(), Some("zai/glm-5"));
2015        assert_eq!(body, "Body\n");
2016    }
2017
2018    #[test]
2019    fn merge_fields_claude_model() {
2020        let content = "---\nmodel: gpt-5\n---\nBody\n";
2021        let result = merge_fields(content, "claude_model: opus").unwrap();
2022        let (fm, _) = parse(&result).unwrap();
2023        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2024        assert_eq!(fm.claude_model.as_deref(), Some("opus"));
2025    }
2026
2027    #[test]
2028    fn merge_fields_codex_model() {
2029        let content = "---\nmodel: gpt-5\n---\nBody\n";
2030        let result = merge_fields(content, "codex_model: o3-pro").unwrap();
2031        let (fm, _) = parse(&result).unwrap();
2032        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2033        assert_eq!(fm.codex_model.as_deref(), Some("o3-pro"));
2034    }
2035
2036    #[test]
2037    fn merge_fields_opencode_model() {
2038        let content = "---\nmodel: gpt-5\n---\nBody\n";
2039        let result = merge_fields(content, "opencode_model: zai/glm-5").unwrap();
2040        let (fm, _) = parse(&result).unwrap();
2041        assert_eq!(fm.model.as_deref(), Some("gpt-5"));
2042        assert_eq!(fm.opencode_model.as_deref(), Some("zai/glm-5"));
2043    }
2044}