Skip to main content

agent_doc/
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//! - `write(fm, body)` serialises `Frontmatter` to YAML and prepends it to `body`, producing a
10//!   complete document string.
11//! - `set_session_id` / `set_resume_id` are convenience wrappers: parse → mutate one field →
12//!   write. They create frontmatter when none exists.
13//! - `ensure_session` is idempotent: if `session` is already set the document is returned
14//!   unchanged; otherwise a UUID v4 is generated and written.
15//! - `set_format_and_write` atomically sets `agent_doc_format` + `agent_doc_write` and clears
16//!   the deprecated `agent_doc_mode` field.
17//! - `merge_fields(content, yaml_fields)` applies additive key/value patches to the frontmatter;
18//!   unknown keys are logged to stderr and discarded; the document body is preserved unchanged.
19//! - `set_tmux_session` is retained for backward compatibility; `tmux_session` in frontmatter is
20//!   deprecated — runtime session is now determined by `--window` or `current_tmux_session()`.
21//! - `AgentDocFormat` (append | template) controls document structure; serialised as `inline` /
22//!   `template` in YAML; `inline` and `append` are accepted as aliases on deserialise.
23//! - `AgentDocWrite` (merge | crdt) controls the write/merge strategy.
24//! - `ResolvedMode` is the canonical (format, write) pair after deprecation migration.
25//!   `Frontmatter::resolve_mode()` applies a three-level priority: explicit `agent_doc_format` /
26//!   `agent_doc_write` fields > deprecated `agent_doc_mode` string > defaults
27//!   (format=template, write=crdt).
28//! - Deprecated `agent_doc_mode` values: `"append"` → format=Append; `"template"` or `"stream"`
29//!   → format=Template; all keep write=Crdt.
30//! - `StreamConfig` carries optional CRDT stream parameters: write-back interval, ANSI stripping,
31//!   target component, and chain-of-thought routing.
32//! - Field renames and aliases ensure backward compatibility:
33//!   `session` / `agent_doc_session` → `session`;
34//!   `mode` / `response_mode` / `agent_doc_mode` → `mode`.
35//!
36//! ## Agentic Contracts
37//! - `parse` never panics; it returns `Err` only for an unterminated frontmatter block or a YAML
38//!   deserialisation failure. All missing optional fields default to `None`.
39//! - `write(parse(doc).0, parse(doc).1)` round-trips the document: re-parsing the result yields
40//!   semantically equivalent `Frontmatter` and an identical body string.
41//! - `ensure_session` is idempotent: calling it twice on the same document returns the same
42//!   session ID and does not modify the document on the second call.
43//! - `merge_fields` is additive: it never removes existing fields unless an explicit `null`
44//!   value is supplied for that field in `yaml_fields`.
45//! - `set_format_and_write` is the only function that actively clears a field (`mode = None`);
46//!   all other helpers are strictly additive.
47//! - `resolve_mode` is pure and total: it always returns a valid `ResolvedMode` regardless of
48//!   which combination of deprecated and current fields are populated.
49//! - Serialised YAML always uses the canonical field names (`agent_doc_session`,
50//!   `agent_doc_mode`, `agent_doc_format`, `agent_doc_write`); legacy aliases are read-only.
51//!
52//! ## Evals
53//! - parse_no_frontmatter: content without `---` prefix → default Frontmatter, full content as body
54//! - parse_all_fields: document with all known fields → each field correctly populated
55//! - parse_null_fields: YAML `null` values → all fields `None`
56//! - parse_unterminated_frontmatter: `---` open with no close → `Err` "Unterminated frontmatter"
57//! - parse_closing_at_eof: closing `---` without trailing newline → body = ""
58//! - write_roundtrip: write then parse → identical Frontmatter, body contains original text
59//! - write_default_frontmatter: default Frontmatter → output starts with `---\n`, ends with `---\n`
60//! - set_session_id_creates_frontmatter: plain doc → frontmatter added with correct session
61//! - set_session_id_preserves_other_fields: existing fields not clobbered by session update
62//! - ensure_session_no_frontmatter: plain doc → UUID generated, written, returned
63//! - ensure_session_existing_session: session already set → content unchanged, same ID returned
64//! - parse_legacy_session_field: `session:` alias → populates `fm.session`
65//! - parse_mode_shorthand_alias: `mode:` alias → populates `fm.mode`
66//! - write_uses_agent_doc_mode_field: deprecated mode serialised as `agent_doc_mode:`, not `mode:`
67//! - write_uses_new_field_name: session serialised as `agent_doc_session:`, not `session:`
68//! - resolve_mode_defaults: empty Frontmatter → format=Template, write=Crdt
69//! - resolve_mode_new_fields_override_deprecated: both present → new fields win
70//! - set_format_and_write_clears_deprecated_mode: deprecated `mode` cleared after migration
71//! - merge_fields_adds_new_field: new key added without disturbing existing fields
72//! - merge_fields_ignores_unknown: unknown key logged to stderr, not stored
73
74use anyhow::Result;
75use serde::{Deserialize, Serialize};
76use std::fmt;
77use uuid::Uuid;
78
79use crate::model_tier::Tier;
80
81/// Document format: controls document structure.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
83pub enum AgentDocFormat {
84    /// Alternating ## User / ## Assistant blocks (also known as "inline")
85    #[clap(alias = "inline")]
86    Append,
87    /// In-place component patching with <!-- agent:name --> markers
88    Template,
89}
90
91impl fmt::Display for AgentDocFormat {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::Append => write!(f, "inline"),
95            Self::Template => write!(f, "template"),
96        }
97    }
98}
99
100impl Serialize for AgentDocFormat {
101    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
102    where
103        S: serde::Serializer,
104    {
105        match self {
106            Self::Append => serializer.serialize_str("inline"),
107            Self::Template => serializer.serialize_str("template"),
108        }
109    }
110}
111
112impl<'de> Deserialize<'de> for AgentDocFormat {
113    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
114    where
115        D: serde::Deserializer<'de>,
116    {
117        let s = String::deserialize(deserializer)?;
118        match s.as_str() {
119            "append" | "inline" => Ok(Self::Append),
120            "template" => Ok(Self::Template),
121            other => Err(serde::de::Error::unknown_variant(other, &["inline", "append", "template"])),
122        }
123    }
124}
125
126/// Write strategy: controls how responses are merged into the document.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
128#[serde(rename_all = "lowercase")]
129pub enum AgentDocWrite {
130    /// 3-way merge via git merge-file
131    Merge,
132    /// CRDT-based conflict-free merge (yrs)
133    Crdt,
134}
135
136impl fmt::Display for AgentDocWrite {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            Self::Merge => write!(f, "merge"),
140            Self::Crdt => write!(f, "crdt"),
141        }
142    }
143}
144
145/// Resolved mode pair — the canonical representation after deprecation migration.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ResolvedMode {
148    pub format: AgentDocFormat,
149    pub write: AgentDocWrite,
150}
151
152impl ResolvedMode {
153    pub fn is_template(&self) -> bool {
154        self.format == AgentDocFormat::Template
155    }
156
157    pub fn is_append(&self) -> bool {
158        self.format == AgentDocFormat::Append
159    }
160
161    pub fn is_crdt(&self) -> bool {
162        self.write == AgentDocWrite::Crdt
163    }
164}
165
166/// Configuration for stream mode (real-time CRDT write-back).
167#[derive(Debug, Default, Clone, Serialize, Deserialize)]
168pub struct StreamConfig {
169    /// Write-back interval in milliseconds (default: 200)
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub interval: Option<u64>,
172    /// Strip ANSI escape codes from agent output (default: true)
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub strip_ansi: Option<bool>,
175    /// Target component name for stream output (default: "exchange")
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub target: Option<String>,
178    /// Include chain-of-thought (thinking) blocks in output (default: false)
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub thinking: Option<bool>,
181    /// Route thinking to a separate component (e.g., "log"). If unset, thinking
182    /// is interleaved with response text in the target component.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub thinking_target: Option<String>,
185    /// Maximum lines for console capture (default: 50). Limits the content
186    /// written to the console component to prevent indefinite growth.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub max_lines: Option<usize>,
189}
190
191#[derive(Debug, Default, Serialize, Deserialize)]
192pub struct Frontmatter {
193    /// Document/routing UUID — permanent identifier for tmux pane routing.
194    /// Serialized as `agent_doc_session` in YAML; reads legacy `session` via alias.
195    #[serde(
196        default,
197        skip_serializing_if = "Option::is_none",
198        rename = "agent_doc_session",
199        alias = "session"
200    )]
201    pub session: Option<String>,
202    /// Agent conversation ID — used for `--resume` with agent backends.
203    /// Separate from `session` so the routing key never changes.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub resume: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub agent: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub model: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub branch: Option<String>,
212    /// **Deprecated.** Tmux session name for pane affinity (e.g., "claude").
213    /// Session is now determined at runtime by `--window` argument (sync) or
214    /// `current_tmux_session()` (route/start). Still read for backward compatibility
215    /// and auto-repaired by sync when it differs from the context session.
216    /// Will be removed in a future version.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub tmux_session: Option<String>,
219    /// **Deprecated.** Use `agent_doc_format` + `agent_doc_write` instead.
220    /// Kept for backward compatibility. Values: "append", "template", "stream".
221    /// Serialized as `agent_doc_mode` in YAML; reads legacy `response_mode` and shorthand `mode` via aliases.
222    #[serde(
223        default,
224        skip_serializing_if = "Option::is_none",
225        rename = "agent_doc_mode",
226        alias = "mode",
227        alias = "response_mode"
228    )]
229    pub mode: Option<String>,
230    /// Document format: controls document structure (append | template).
231    #[serde(
232        default,
233        skip_serializing_if = "Option::is_none",
234        rename = "agent_doc_format"
235    )]
236    pub format: Option<AgentDocFormat>,
237    /// Write strategy: controls merge behavior (merge | crdt).
238    #[serde(
239        default,
240        skip_serializing_if = "Option::is_none",
241        rename = "agent_doc_write"
242    )]
243    pub write_mode: Option<AgentDocWrite>,
244    /// Stream mode configuration (used when write strategy is CRDT).
245    #[serde(
246        default,
247        skip_serializing_if = "Option::is_none",
248        rename = "agent_doc_stream"
249    )]
250    pub stream_config: Option<StreamConfig>,
251    /// Additional CLI arguments to pass to the `claude` process.
252    /// Space-separated string (e.g., "--dangerously-skip-permissions").
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub claude_args: Option<String>,
255    /// When true, passes `--no-mcp` to the `claude` process.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub no_mcp: Option<bool>,
258    /// When true, passes `--enable-tool-search` to the `claude` process.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub enable_tool_search: Option<bool>,
261    /// Debounce duration in milliseconds for preflight mtime settling.
262    /// Default: 2000ms. Set to 0 to disable debounce (run immediately).
263    #[serde(
264        default,
265        skip_serializing_if = "Option::is_none",
266        rename = "agent_doc_debounce"
267    )]
268    pub debounce_ms: Option<u64>,
269    /// Linked resources: local file paths or URLs (relative to this document's directory).
270    /// Changes in linked docs are surfaced during preflight.
271    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
272    pub links: Vec<String>,
273    /// Auto-compact threshold: line count for the exchange component.
274    /// When the exchange component exceeds this many lines, preflight automatically
275    /// runs compact before computing the diff. Set to 0 or omit to disable.
276    #[serde(
277        default,
278        skip_serializing_if = "Option::is_none",
279        rename = "agent_doc_auto_compact"
280    )]
281    pub auto_compact: Option<usize>,
282    /// Required model tier for this document. When set, preflight emits this as
283    /// `required_tier`, which the skill uses as a hard gate: if the running model's
284    /// tier is below this value, the skill writes a switch prompt and stops.
285    /// Values: `auto | low | med | high`. Default: absent (no gate).
286    #[serde(
287        default,
288        skip_serializing_if = "Option::is_none",
289        rename = "agent_doc_model_tier"
290    )]
291    pub model_tier: Option<Tier>,
292    /// Document-level lifecycle hooks: shell commands executed at key events.
293    ///
294    /// Supported events: `session_start`, `post_write`, `post_commit`.
295    /// Each event maps to a list of shell commands (run via `sh -c`).
296    ///
297    /// Template variables substituted before execution:
298    /// - `{{session_id}}` — document session UUID
299    /// - `{{file}}` — document file path
300    /// - `{{agent}}` — agent name (or empty string)
301    /// - `{{model}}` — model name (or empty string)
302    ///
303    /// Execution is best-effort: failures log to stderr and never block the session.
304    ///
305    /// Example:
306    /// ```yaml
307    /// hooks:
308    ///   session_start:
309    ///     - "curl -s -X POST https://api.example.com/sessions -d '{\"session_id\": \"{{session_id}}\"}'"
310    ///   post_write:
311    ///     - "notify-send 'agent-doc' 'Response written to {{file}}'"
312    /// ```
313    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
314    pub hooks: std::collections::HashMap<String, Vec<String>>,
315    /// Environment variables to inject into the Claude session.
316    /// Keys are env var names, values are strings supporting shell expansion
317    /// (`$(command)`, `$VAR`, `${VAR}`). Order is preserved: later values can
318    /// reference earlier keys. A `null` value (YAML `KEY: null`) means "unset
319    /// this key in the child env" — supported by every consumer (start.rs,
320    /// run.rs, stream.rs, parallel.rs, supervisor/env.rs).
321    #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
322    pub env: indexmap::IndexMap<String, Option<String>>,
323    /// Whether the supervisor starts from the parent process env before
324    /// applying the `env:` overlay. Default `true` — the supervised claude
325    /// child inherits PATH/HOME/TERM/TMUX/... from the shell that launched
326    /// agent-doc, then frontmatter `env:` overrides/unsets specific keys.
327    /// Set `false` for a sealed env (only the explicit `env:` keys, nothing
328    /// from the parent). Consumed by `supervisor/env.rs::EnvSpec`.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub agent_doc_env_inherit: Option<bool>,
331    /// Explicit working directory for the supervised claude child process.
332    /// Resolved relative to the document's parent directory if a relative path
333    /// is given. When absent, the supervisor falls back to project-root detection
334    /// (`.agent-doc/` walk) or the document's parent directory.
335    /// See `src/agent-doc/specs/supervisor.md` § Core Invariants / CWD determinism.
336    #[serde(
337        default,
338        skip_serializing_if = "Option::is_none",
339        rename = "agent_doc_cwd"
340    )]
341    pub cwd: Option<String>,
342}
343
344impl Frontmatter {
345    /// Resolve the canonical (format, write) pair from all three fields.
346    ///
347    /// Priority:
348    /// 1. Explicit `agent_doc_format` / `agent_doc_write` fields (highest)
349    /// 2. Deprecated `agent_doc_mode` field (auto-migrated)
350    /// 3. Defaults: format=template, write=crdt
351    pub fn resolve_mode(&self) -> ResolvedMode {
352        // Start with defaults
353        let mut format = AgentDocFormat::Template;
354        let mut write = AgentDocWrite::Crdt;
355
356        // Apply deprecated mode if present (lowest priority)
357        if let Some(ref mode_str) = self.mode {
358            match mode_str.as_str() {
359                "append" => {
360                    format = AgentDocFormat::Append;
361                    // write stays crdt (user preference: always crdt)
362                }
363                "template" => {
364                    format = AgentDocFormat::Template;
365                    // write stays crdt
366                }
367                "stream" => {
368                    format = AgentDocFormat::Template;
369                    write = AgentDocWrite::Crdt;
370                }
371                _ => {} // unknown mode, use defaults
372            }
373        }
374
375        // Override with explicit new fields (highest priority)
376        if let Some(f) = self.format {
377            format = f;
378        }
379        if let Some(w) = self.write_mode {
380            write = w;
381        }
382
383        ResolvedMode { format, write }
384    }
385}
386
387/// Parse YAML frontmatter from a document. Returns (frontmatter, body).
388/// If no frontmatter block is present, returns defaults and the full content as body.
389pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
390    if !content.starts_with("---\n") {
391        return Ok((Frontmatter::default(), content));
392    }
393    let rest = &content[4..]; // skip opening ---\n
394    let end = rest
395        .find("\n---\n")
396        .or_else(|| rest.find("\n---"))
397        .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
398    let yaml = &rest[..end];
399    let fm: Frontmatter = serde_yaml::from_str(yaml)?;
400    let body_start = 4 + end + 4; // opening --- + yaml + closing ---\n
401    let body = if body_start <= content.len() {
402        &content[body_start..]
403    } else {
404        ""
405    };
406    Ok((fm, body))
407}
408
409/// Write frontmatter back into a document, preserving the body.
410pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
411    let yaml = serde_yaml::to_string(fm)?;
412    Ok(format!("---\n{}---\n{}", yaml, body))
413}
414
415/// Update the session ID in a document string. Creates frontmatter if missing.
416pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
417    let (mut fm, body) = parse(content)?;
418    fm.session = Some(session_id.to_string());
419    write(&fm, body)
420}
421
422/// Update the resume (agent conversation) ID in a document string.
423pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
424    let (mut fm, body) = parse(content)?;
425    fm.resume = Some(resume_id.to_string());
426    write(&fm, body)
427}
428
429/// Set both agent_doc_format and agent_doc_write, clearing deprecated agent_doc_mode.
430pub fn set_format_and_write(
431    content: &str,
432    format: AgentDocFormat,
433    write_mode: AgentDocWrite,
434) -> Result<String> {
435    let (mut fm, body) = parse(content)?;
436    fm.format = Some(format);
437    fm.write_mode = Some(write_mode);
438    fm.mode = None;
439    write(&fm, body)
440}
441
442/// Merge YAML key/value pairs into a document's frontmatter.
443///
444/// Takes a YAML string of fields to merge (additive — never removes keys).
445/// Only known frontmatter fields are applied; unknown keys are ignored.
446/// Returns the updated document content.
447pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
448    let (mut fm, body) = parse(content)?;
449    let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
450        .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
451    let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
452
453    for (key, value) in &mapping {
454        let key_str = key.as_str().unwrap_or("");
455        let val_str = || value.as_str().map(|s| s.to_string());
456        match key_str {
457            "agent_doc_session" | "session" => fm.session = val_str(),
458            "resume" => fm.resume = val_str(),
459            "agent" => fm.agent = val_str(),
460            "model" => fm.model = val_str(),
461            "branch" => fm.branch = val_str(),
462            "tmux_session" => fm.tmux_session = val_str(),
463            "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
464            "agent_doc_format" => {
465                if let Some(s) = value.as_str()
466                    && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
467                {
468                    fm.format = Some(f);
469                }
470            }
471            "agent_doc_write" => {
472                if let Some(s) = value.as_str()
473                    && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
474                {
475                    fm.write_mode = Some(w);
476                }
477            }
478            "claude_args" => fm.claude_args = val_str(),
479            _ => {
480                eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
481            }
482        }
483    }
484
485    write(&fm, body)
486}
487
488/// Update the tmux_session name in a document string.
489///
490/// **Deprecated.** `tmux_session` in frontmatter is deprecated — session is now
491/// determined at runtime. This function is retained for backward compatibility
492/// (claim and sync still write it so older binaries can read it).
493pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
494    let (mut fm, body) = parse(content)?;
495    fm.tmux_session = Some(session_name.to_string());
496    write(&fm, body)
497}
498
499/// Ensure the document has a session ID. If no frontmatter exists, creates one
500/// with a new UUID v4. If frontmatter exists but session is None/null, generates
501/// a UUID and sets it. If session already exists, returns as-is.
502/// Returns (updated_content, session_id).
503pub fn ensure_session(content: &str) -> Result<(String, String)> {
504    let (fm, _body) = parse(content)?;
505    if let Some(ref session_id) = fm.session {
506        // Session already set — return content unchanged
507        return Ok((content.to_string(), session_id.clone()));
508    }
509    let session_id = Uuid::new_v4().to_string();
510    let updated = set_session_id(content, &session_id)?;
511    Ok((updated, session_id))
512}
513
514/// Read the session UUID from a document file. Returns empty string if not found.
515pub fn read_session_id(file: &std::path::Path) -> Option<String> {
516    let content = std::fs::read_to_string(file).ok()?;
517    let (fm, _) = parse(&content).ok()?;
518    fm.session
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn parse_no_frontmatter() {
527        let content = "# Hello\n\nBody text.\n";
528        let (fm, body) = parse(content).unwrap();
529        assert!(fm.session.is_none());
530        assert!(fm.agent.is_none());
531        assert!(fm.model.is_none());
532        assert!(fm.branch.is_none());
533        assert_eq!(body, content);
534    }
535
536    #[test]
537    fn parse_all_fields() {
538        let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
539        let (fm, body) = parse(content).unwrap();
540        assert_eq!(fm.session.as_deref(), Some("abc-123"));
541        assert_eq!(fm.agent.as_deref(), Some("claude"));
542        assert_eq!(fm.model.as_deref(), Some("opus"));
543        assert_eq!(fm.branch.as_deref(), Some("main"));
544        assert!(body.contains("Body"));
545    }
546
547    #[test]
548    fn parse_partial_fields() {
549        let content = "---\nsession: xyz\n---\n# Doc\n";
550        let (fm, body) = parse(content).unwrap();
551        assert_eq!(fm.session.as_deref(), Some("xyz"));
552        assert!(fm.agent.is_none());
553        assert!(body.contains("# Doc"));
554    }
555
556    #[test]
557    fn parse_model_tier_high() {
558        let content = "---\nagent_doc_model_tier: high\n---\nBody\n";
559        let (fm, _) = parse(content).unwrap();
560        assert_eq!(fm.model_tier, Some(Tier::High));
561    }
562
563    #[test]
564    fn parse_model_tier_low() {
565        let content = "---\nagent_doc_model_tier: low\n---\nBody\n";
566        let (fm, _) = parse(content).unwrap();
567        assert_eq!(fm.model_tier, Some(Tier::Low));
568    }
569
570    #[test]
571    fn parse_model_tier_med() {
572        let content = "---\nagent_doc_model_tier: med\n---\nBody\n";
573        let (fm, _) = parse(content).unwrap();
574        assert_eq!(fm.model_tier, Some(Tier::Med));
575    }
576
577    #[test]
578    fn parse_model_tier_auto() {
579        let content = "---\nagent_doc_model_tier: auto\n---\nBody\n";
580        let (fm, _) = parse(content).unwrap();
581        assert_eq!(fm.model_tier, Some(Tier::Auto));
582    }
583
584    #[test]
585    fn parse_model_tier_absent() {
586        let content = "---\nagent: claude\n---\nBody\n";
587        let (fm, _) = parse(content).unwrap();
588        assert_eq!(fm.model_tier, None);
589    }
590
591    #[test]
592    fn parse_model_tier_invalid_rejected() {
593        let content = "---\nagent_doc_model_tier: ultra\n---\nBody\n";
594        let result = parse(content);
595        assert!(result.is_err(), "invalid tier value should fail to parse");
596    }
597
598    #[test]
599    fn write_model_tier_roundtrip() {
600        let fm = Frontmatter {
601            model_tier: Some(Tier::High),
602            ..Default::default()
603        };
604        let doc = write(&fm, "Body\n").unwrap();
605        let (parsed, _) = parse(&doc).unwrap();
606        assert_eq!(parsed.model_tier, Some(Tier::High));
607        assert!(doc.contains("agent_doc_model_tier: high"));
608    }
609
610    #[test]
611    fn parse_null_fields() {
612        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
613        let (fm, body) = parse(content).unwrap();
614        assert!(fm.session.is_none());
615        assert!(fm.agent.is_none());
616        assert!(fm.model.is_none());
617        assert!(fm.branch.is_none());
618        assert!(body.contains("Body"));
619    }
620
621    #[test]
622    fn parse_unterminated_frontmatter() {
623        let content = "---\nsession: abc\nno closing block";
624        let err = parse(content).unwrap_err();
625        assert!(err.to_string().contains("Unterminated frontmatter"));
626    }
627
628    #[test]
629    fn parse_closing_at_eof() {
630        let content = "---\nsession: abc\n---";
631        let (fm, body) = parse(content).unwrap();
632        assert_eq!(fm.session.as_deref(), Some("abc"));
633        assert_eq!(body, "");
634    }
635
636    #[test]
637    fn parse_empty_body() {
638        let content = "---\nsession: abc\n---\n";
639        let (fm, _body) = parse(content).unwrap();
640        assert_eq!(fm.session.as_deref(), Some("abc"));
641    }
642
643    #[test]
644    fn write_roundtrip() {
645        // Start from write output to ensure consistent formatting
646        let fm = Frontmatter {
647            session: Some("test-id".to_string()),
648            resume: Some("resume-id".to_string()),
649            agent: Some("claude".to_string()),
650            model: Some("opus".to_string()),
651            branch: Some("dev".to_string()),
652            tmux_session: None,
653            mode: None,
654            format: None,
655            write_mode: None,
656            stream_config: None,
657            claude_args: None,
658            no_mcp: None,
659            enable_tool_search: None,
660            debounce_ms: None,
661            links: vec![],
662            auto_compact: None,
663            model_tier: None,
664            hooks: std::collections::HashMap::new(),
665            env: indexmap::IndexMap::new(),
666            agent_doc_env_inherit: None,
667            cwd: None,
668        };
669        let body = "# Hello\n\nBody text.\n";
670        let written = write(&fm, body).unwrap();
671        let (fm2, body2) = parse(&written).unwrap();
672        assert_eq!(fm2.session, fm.session);
673        assert_eq!(fm2.agent, fm.agent);
674        assert_eq!(fm2.model, fm.model);
675        assert_eq!(fm2.branch, fm.branch);
676        // Roundtrip preserves body (may have leading newline from parse)
677        assert!(body2.contains("# Hello"));
678        assert!(body2.contains("Body text."));
679    }
680
681    #[test]
682    fn write_default_frontmatter() {
683        let fm = Frontmatter::default();
684        let result = write(&fm, "body\n").unwrap();
685        assert!(result.starts_with("---\n"));
686        assert!(result.ends_with("---\nbody\n"));
687    }
688
689    #[test]
690    fn write_preserves_body_content() {
691        let fm = Frontmatter::default();
692        let body = "# Title\n\nSome **markdown** with `code`.\n";
693        let result = write(&fm, body).unwrap();
694        assert!(result.contains("# Title"));
695        assert!(result.contains("Some **markdown** with `code`."));
696    }
697
698    #[test]
699    fn set_session_id_creates_frontmatter() {
700        let content = "# No frontmatter\n\nJust body.\n";
701        let result = set_session_id(content, "new-session").unwrap();
702        let (fm, body) = parse(&result).unwrap();
703        assert_eq!(fm.session.as_deref(), Some("new-session"));
704        assert!(body.contains("# No frontmatter"));
705    }
706
707    #[test]
708    fn set_session_id_updates_existing() {
709        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
710        let result = set_session_id(content, "new-id").unwrap();
711        let (fm, body) = parse(&result).unwrap();
712        assert_eq!(fm.session.as_deref(), Some("new-id"));
713        assert_eq!(fm.agent.as_deref(), Some("claude"));
714        assert!(body.contains("Body"));
715    }
716
717    #[test]
718    fn set_session_id_preserves_other_fields() {
719        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
720        let result = set_session_id(content, "new").unwrap();
721        let (fm, _) = parse(&result).unwrap();
722        assert_eq!(fm.session.as_deref(), Some("new"));
723        assert_eq!(fm.agent.as_deref(), Some("claude"));
724        assert_eq!(fm.model.as_deref(), Some("opus"));
725        assert_eq!(fm.branch.as_deref(), Some("dev"));
726    }
727
728    #[test]
729    fn ensure_session_no_frontmatter() {
730        let content = "# Hello\n\nBody.\n";
731        let (updated, sid) = ensure_session(content).unwrap();
732        // Should have generated a UUID
733        assert_eq!(sid.len(), 36); // UUID v4 string length
734        let (fm, body) = parse(&updated).unwrap();
735        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
736        assert!(body.contains("# Hello"));
737    }
738
739    #[test]
740    fn ensure_session_null_session() {
741        let content = "---\nsession:\nagent: claude\n---\nBody\n";
742        let (updated, sid) = ensure_session(content).unwrap();
743        assert_eq!(sid.len(), 36);
744        let (fm, body) = parse(&updated).unwrap();
745        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
746        assert_eq!(fm.agent.as_deref(), Some("claude"));
747        assert!(body.contains("Body"));
748    }
749
750    #[test]
751    fn ensure_session_existing_session() {
752        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
753        let (updated, sid) = ensure_session(content).unwrap();
754        assert_eq!(sid, "existing-id");
755        // Content should be unchanged
756        assert_eq!(updated, content);
757    }
758
759    #[test]
760    fn parse_legacy_session_field() {
761        // Old `session:` field should still parse via serde alias
762        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
763        let (fm, body) = parse(content).unwrap();
764        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
765        assert_eq!(fm.agent.as_deref(), Some("claude"));
766        assert!(body.contains("Body"));
767    }
768
769    #[test]
770    fn parse_agent_doc_mode_canonical() {
771        let content = "---\nagent_doc_mode: template\n---\nBody\n";
772        let (fm, _) = parse(content).unwrap();
773        assert_eq!(fm.mode.as_deref(), Some("template"));
774    }
775
776    #[test]
777    fn parse_mode_shorthand_alias() {
778        let content = "---\nmode: template\n---\nBody\n";
779        let (fm, _) = parse(content).unwrap();
780        assert_eq!(fm.mode.as_deref(), Some("template"));
781    }
782
783    #[test]
784    fn parse_response_mode_legacy_alias() {
785        let content = "---\nresponse_mode: template\n---\nBody\n";
786        let (fm, _) = parse(content).unwrap();
787        assert_eq!(fm.mode.as_deref(), Some("template"));
788    }
789
790    #[test]
791    fn write_uses_agent_doc_mode_field() {
792        #[allow(deprecated)]
793        let fm = Frontmatter {
794            mode: Some("template".to_string()),
795            ..Default::default()
796        };
797        let result = write(&fm, "body\n").unwrap();
798        assert!(result.contains("agent_doc_mode:"));
799        assert!(!result.contains("response_mode:"));
800        assert!(!result.contains("\nmode:"));
801    }
802
803    #[test]
804    fn write_uses_new_field_name() {
805        let fm = Frontmatter {
806            session: Some("test-id".to_string()),
807            ..Default::default()
808        };
809        let result = write(&fm, "body\n").unwrap();
810        assert!(result.contains("agent_doc_session:"));
811        assert!(!result.contains("\nsession:"));
812    }
813
814    // --- resolve_mode tests ---
815
816    #[test]
817    fn resolve_mode_defaults() {
818        let fm = Frontmatter::default();
819        let resolved = fm.resolve_mode();
820        assert_eq!(resolved.format, AgentDocFormat::Template);
821        assert_eq!(resolved.write, AgentDocWrite::Crdt);
822    }
823
824    #[test]
825    fn resolve_mode_from_deprecated_append() {
826        let content = "---\nagent_doc_mode: append\n---\nBody\n";
827        let (fm, _) = parse(content).unwrap();
828        let resolved = fm.resolve_mode();
829        assert_eq!(resolved.format, AgentDocFormat::Append);
830        assert_eq!(resolved.write, AgentDocWrite::Crdt);
831    }
832
833    #[test]
834    fn resolve_mode_from_deprecated_template() {
835        let content = "---\nagent_doc_mode: template\n---\nBody\n";
836        let (fm, _) = parse(content).unwrap();
837        let resolved = fm.resolve_mode();
838        assert_eq!(resolved.format, AgentDocFormat::Template);
839        assert_eq!(resolved.write, AgentDocWrite::Crdt);
840    }
841
842    #[test]
843    fn resolve_mode_from_deprecated_stream() {
844        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
845        let (fm, _) = parse(content).unwrap();
846        let resolved = fm.resolve_mode();
847        assert_eq!(resolved.format, AgentDocFormat::Template);
848        assert_eq!(resolved.write, AgentDocWrite::Crdt);
849    }
850
851    #[test]
852    fn resolve_mode_new_fields_override_deprecated() {
853        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
854        let (fm, _) = parse(content).unwrap();
855        let resolved = fm.resolve_mode();
856        assert_eq!(resolved.format, AgentDocFormat::Template);
857        assert_eq!(resolved.write, AgentDocWrite::Merge);
858    }
859
860    #[test]
861    fn resolve_mode_explicit_new_fields_only() {
862        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
863        let (fm, _) = parse(content).unwrap();
864        let resolved = fm.resolve_mode();
865        assert_eq!(resolved.format, AgentDocFormat::Append);
866        assert_eq!(resolved.write, AgentDocWrite::Crdt);
867    }
868
869    #[test]
870    fn resolve_mode_partial_new_field_format_only() {
871        let content = "---\nagent_doc_format: append\n---\nBody\n";
872        let (fm, _) = parse(content).unwrap();
873        let resolved = fm.resolve_mode();
874        assert_eq!(resolved.format, AgentDocFormat::Append);
875        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
876    }
877
878    #[test]
879    fn resolve_mode_partial_new_field_write_only() {
880        let content = "---\nagent_doc_write: merge\n---\nBody\n";
881        let (fm, _) = parse(content).unwrap();
882        let resolved = fm.resolve_mode();
883        assert_eq!(resolved.format, AgentDocFormat::Template); // default
884        assert_eq!(resolved.write, AgentDocWrite::Merge);
885    }
886
887    #[test]
888    fn resolve_mode_helper_methods() {
889        let fm = Frontmatter::default();
890        let resolved = fm.resolve_mode();
891        assert!(resolved.is_template());
892        assert!(!resolved.is_append());
893        assert!(resolved.is_crdt());
894    }
895
896    #[test]
897    fn parse_new_format_field() {
898        let content = "---\nagent_doc_format: template\n---\nBody\n";
899        let (fm, _) = parse(content).unwrap();
900        assert_eq!(fm.format, Some(AgentDocFormat::Template));
901    }
902
903    #[test]
904    fn parse_new_write_field() {
905        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
906        let (fm, _) = parse(content).unwrap();
907        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
908    }
909
910    #[test]
911    fn write_uses_new_format_write_fields() {
912        let fm = Frontmatter {
913            format: Some(AgentDocFormat::Template),
914            write_mode: Some(AgentDocWrite::Crdt),
915            ..Default::default()
916        };
917        let result = write(&fm, "body\n").unwrap();
918        assert!(result.contains("agent_doc_format:"));
919        assert!(result.contains("agent_doc_write:"));
920        assert!(!result.contains("agent_doc_mode:"));
921    }
922
923    #[test]
924    fn set_format_and_write_clears_deprecated_mode() {
925        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
926        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
927        let (fm, _) = parse(&result).unwrap();
928        assert!(fm.mode.is_none());
929        assert_eq!(fm.format, Some(AgentDocFormat::Template));
930        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
931    }
932
933    // --- merge_fields tests ---
934
935    #[test]
936    fn merge_fields_adds_new_field() {
937        let content = "---\nagent_doc_session: abc\n---\nBody\n";
938        let result = merge_fields(content, "model: opus").unwrap();
939        let (fm, body) = parse(&result).unwrap();
940        assert_eq!(fm.session.as_deref(), Some("abc"));
941        assert_eq!(fm.model.as_deref(), Some("opus"));
942        assert!(body.contains("Body"));
943    }
944
945    #[test]
946    fn merge_fields_updates_existing_field() {
947        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
948        let result = merge_fields(content, "model: opus").unwrap();
949        let (fm, _) = parse(&result).unwrap();
950        assert_eq!(fm.model.as_deref(), Some("opus"));
951        assert_eq!(fm.session.as_deref(), Some("abc"));
952    }
953
954    #[test]
955    fn merge_fields_multiple_fields() {
956        let content = "---\nagent_doc_session: abc\n---\nBody\n";
957        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
958        let (fm, _) = parse(&result).unwrap();
959        assert_eq!(fm.model.as_deref(), Some("opus"));
960        assert_eq!(fm.agent.as_deref(), Some("claude"));
961        assert_eq!(fm.branch.as_deref(), Some("main"));
962    }
963
964    #[test]
965    fn merge_fields_format_enum() {
966        let content = "---\nagent_doc_session: abc\n---\nBody\n";
967        let result = merge_fields(content, "agent_doc_format: append").unwrap();
968        let (fm, _) = parse(&result).unwrap();
969        assert_eq!(fm.format, Some(AgentDocFormat::Append));
970    }
971
972    #[test]
973    fn merge_fields_write_enum() {
974        let content = "---\nagent_doc_session: abc\n---\nBody\n";
975        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
976        let (fm, _) = parse(&result).unwrap();
977        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
978    }
979
980    #[test]
981    fn merge_fields_ignores_unknown() {
982        let content = "---\nagent_doc_session: abc\n---\nBody\n";
983        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
984        let (fm, _) = parse(&result).unwrap();
985        assert_eq!(fm.model.as_deref(), Some("opus"));
986    }
987
988    #[test]
989    fn merge_fields_preserves_body() {
990        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
991        let result = merge_fields(content, "model: opus").unwrap();
992        assert!(result.contains("# Title"));
993        assert!(result.contains("Some **markdown** content."));
994    }
995
996    #[test]
997    fn set_format_and_write_clears_deprecated() {
998        let content = "---\nagent_doc_mode: append\n---\nBody\n";
999        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
1000        let (fm, _) = parse(&result).unwrap();
1001        assert!(fm.mode.is_none());
1002        assert_eq!(fm.format, Some(AgentDocFormat::Template));
1003        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
1004    }
1005
1006    #[test]
1007    fn hooks_roundtrip() {
1008        let content = "---\nhooks:\n  session_start:\n    - \"echo start {{session_id}}\"\n  post_write:\n    - \"notify {{file}}\"\n---\nBody\n";
1009        let (fm, _) = parse(content).unwrap();
1010        assert_eq!(fm.hooks.get("session_start"), Some(&vec!["echo start {{session_id}}".to_string()]));
1011        assert_eq!(fm.hooks.get("post_write"), Some(&vec!["notify {{file}}".to_string()]));
1012    }
1013
1014    #[test]
1015    fn hooks_omitted_when_empty() {
1016        let fm = Frontmatter::default();
1017        let result = write(&fm, "body\n").unwrap();
1018        assert!(!result.contains("hooks"));
1019    }
1020
1021    #[test]
1022    fn hooks_absent_parses_as_empty() {
1023        let content = "---\nsession: abc\n---\nBody\n";
1024        let (fm, _) = parse(content).unwrap();
1025        assert!(fm.hooks.is_empty());
1026    }
1027
1028    #[test]
1029    fn parse_no_mcp_field() {
1030        let content = "---\nno_mcp: true\n---\nBody\n";
1031        let (fm, _) = parse(content).unwrap();
1032        assert_eq!(fm.no_mcp, Some(true));
1033    }
1034
1035    #[test]
1036    fn parse_enable_tool_search_field() {
1037        let content = "---\nenable_tool_search: true\n---\nBody\n";
1038        let (fm, _) = parse(content).unwrap();
1039        assert_eq!(fm.enable_tool_search, Some(true));
1040    }
1041
1042    #[test]
1043    fn parse_missing_flags_default_none() {
1044        let content = "---\nsession: abc\n---\nBody\n";
1045        let (fm, _) = parse(content).unwrap();
1046        assert!(fm.no_mcp.is_none());
1047        assert!(fm.enable_tool_search.is_none());
1048    }
1049
1050    #[test]
1051    fn parse_env_map() {
1052        let content = "---\nenv:\n  FOO: bar\n  BAZ: \"$(echo hello)\"\n---\nBody\n";
1053        let (fm, _) = parse(content).unwrap();
1054        assert_eq!(fm.env.len(), 2);
1055        assert_eq!(fm.env["FOO"], Some("bar".to_string()));
1056        assert_eq!(fm.env["BAZ"], Some("$(echo hello)".to_string()));
1057        // Verify order is preserved
1058        let keys: Vec<&String> = fm.env.keys().collect();
1059        assert_eq!(keys, vec!["FOO", "BAZ"]);
1060    }
1061
1062    #[test]
1063    fn parse_env_empty_default() {
1064        let content = "---\nsession: abc\n---\nBody\n";
1065        let (fm, _) = parse(content).unwrap();
1066        assert!(fm.env.is_empty());
1067    }
1068
1069    #[test]
1070    fn parse_env_unset_via_null() {
1071        let content = "---\nenv:\n  SET_ME: value\n  UNSET_ME: null\n---\nBody\n";
1072        let (fm, _) = parse(content).unwrap();
1073        assert_eq!(fm.env.len(), 2);
1074        assert_eq!(fm.env["SET_ME"], Some("value".to_string()));
1075        assert_eq!(fm.env["UNSET_ME"], None);
1076    }
1077
1078    #[test]
1079    fn write_roundtrip_with_env() {
1080        let mut env: indexmap::IndexMap<String, Option<String>> = indexmap::IndexMap::new();
1081        env.insert("KEY1".to_string(), Some("value1".to_string()));
1082        env.insert("KEY2".to_string(), Some("$KEY1".to_string()));
1083        env.insert("KEY3".to_string(), None);
1084        let fm = Frontmatter {
1085            env,
1086            ..Default::default()
1087        };
1088        let written = write(&fm, "body\n").unwrap();
1089        let (fm2, _) = parse(&written).unwrap();
1090        assert_eq!(fm2.env.len(), 3);
1091        assert_eq!(fm2.env["KEY1"], Some("value1".to_string()));
1092        assert_eq!(fm2.env["KEY2"], Some("$KEY1".to_string()));
1093        assert_eq!(fm2.env["KEY3"], None);
1094    }
1095}