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
79/// Document format: controls document structure.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
81pub enum AgentDocFormat {
82    /// Alternating ## User / ## Assistant blocks (also known as "inline")
83    #[clap(alias = "inline")]
84    Append,
85    /// In-place component patching with <!-- agent:name --> markers
86    Template,
87}
88
89impl fmt::Display for AgentDocFormat {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::Append => write!(f, "inline"),
93            Self::Template => write!(f, "template"),
94        }
95    }
96}
97
98impl Serialize for AgentDocFormat {
99    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
100    where
101        S: serde::Serializer,
102    {
103        match self {
104            Self::Append => serializer.serialize_str("inline"),
105            Self::Template => serializer.serialize_str("template"),
106        }
107    }
108}
109
110impl<'de> Deserialize<'de> for AgentDocFormat {
111    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
112    where
113        D: serde::Deserializer<'de>,
114    {
115        let s = String::deserialize(deserializer)?;
116        match s.as_str() {
117            "append" | "inline" => Ok(Self::Append),
118            "template" => Ok(Self::Template),
119            other => Err(serde::de::Error::unknown_variant(other, &["inline", "append", "template"])),
120        }
121    }
122}
123
124/// Write strategy: controls how responses are merged into the document.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
126#[serde(rename_all = "lowercase")]
127pub enum AgentDocWrite {
128    /// 3-way merge via git merge-file
129    Merge,
130    /// CRDT-based conflict-free merge (yrs)
131    Crdt,
132}
133
134impl fmt::Display for AgentDocWrite {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        match self {
137            Self::Merge => write!(f, "merge"),
138            Self::Crdt => write!(f, "crdt"),
139        }
140    }
141}
142
143/// Resolved mode pair — the canonical representation after deprecation migration.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct ResolvedMode {
146    pub format: AgentDocFormat,
147    pub write: AgentDocWrite,
148}
149
150impl ResolvedMode {
151    pub fn is_template(&self) -> bool {
152        self.format == AgentDocFormat::Template
153    }
154
155    pub fn is_append(&self) -> bool {
156        self.format == AgentDocFormat::Append
157    }
158
159    pub fn is_crdt(&self) -> bool {
160        self.write == AgentDocWrite::Crdt
161    }
162}
163
164/// Configuration for stream mode (real-time CRDT write-back).
165#[derive(Debug, Default, Clone, Serialize, Deserialize)]
166pub struct StreamConfig {
167    /// Write-back interval in milliseconds (default: 200)
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub interval: Option<u64>,
170    /// Strip ANSI escape codes from agent output (default: true)
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub strip_ansi: Option<bool>,
173    /// Target component name for stream output (default: "exchange")
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub target: Option<String>,
176    /// Include chain-of-thought (thinking) blocks in output (default: false)
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub thinking: Option<bool>,
179    /// Route thinking to a separate component (e.g., "log"). If unset, thinking
180    /// is interleaved with response text in the target component.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub thinking_target: Option<String>,
183    /// Maximum lines for console capture (default: 50). Limits the content
184    /// written to the console component to prevent indefinite growth.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub max_lines: Option<usize>,
187}
188
189#[derive(Debug, Default, Serialize, Deserialize)]
190pub struct Frontmatter {
191    /// Document/routing UUID — permanent identifier for tmux pane routing.
192    /// Serialized as `agent_doc_session` in YAML; reads legacy `session` via alias.
193    #[serde(
194        default,
195        skip_serializing_if = "Option::is_none",
196        rename = "agent_doc_session",
197        alias = "session"
198    )]
199    pub session: Option<String>,
200    /// Agent conversation ID — used for `--resume` with agent backends.
201    /// Separate from `session` so the routing key never changes.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub resume: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub agent: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub model: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub branch: Option<String>,
210    /// **Deprecated.** Tmux session name for pane affinity (e.g., "claude").
211    /// Session is now determined at runtime by `--window` argument (sync) or
212    /// `current_tmux_session()` (route/start). Still read for backward compatibility
213    /// and auto-repaired by sync when it differs from the context session.
214    /// Will be removed in a future version.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub tmux_session: Option<String>,
217    /// **Deprecated.** Use `agent_doc_format` + `agent_doc_write` instead.
218    /// Kept for backward compatibility. Values: "append", "template", "stream".
219    /// Serialized as `agent_doc_mode` in YAML; reads legacy `response_mode` and shorthand `mode` via aliases.
220    #[serde(
221        default,
222        skip_serializing_if = "Option::is_none",
223        rename = "agent_doc_mode",
224        alias = "mode",
225        alias = "response_mode"
226    )]
227    pub mode: Option<String>,
228    /// Document format: controls document structure (append | template).
229    #[serde(
230        default,
231        skip_serializing_if = "Option::is_none",
232        rename = "agent_doc_format"
233    )]
234    pub format: Option<AgentDocFormat>,
235    /// Write strategy: controls merge behavior (merge | crdt).
236    #[serde(
237        default,
238        skip_serializing_if = "Option::is_none",
239        rename = "agent_doc_write"
240    )]
241    pub write_mode: Option<AgentDocWrite>,
242    /// Stream mode configuration (used when write strategy is CRDT).
243    #[serde(
244        default,
245        skip_serializing_if = "Option::is_none",
246        rename = "agent_doc_stream"
247    )]
248    pub stream_config: Option<StreamConfig>,
249    /// Additional CLI arguments to pass to the `claude` process.
250    /// Space-separated string (e.g., "--dangerously-skip-permissions").
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub claude_args: Option<String>,
253    /// Debounce duration in milliseconds for preflight mtime settling.
254    /// Default: 2000ms. Set to 0 to disable debounce (run immediately).
255    #[serde(
256        default,
257        skip_serializing_if = "Option::is_none",
258        rename = "agent_doc_debounce"
259    )]
260    pub debounce_ms: Option<u64>,
261    /// Linked resources: local file paths or URLs (relative to this document's directory).
262    /// Changes in linked docs are surfaced during preflight.
263    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "related_docs")]
264    pub links: Vec<String>,
265    /// Auto-compact threshold: line count for the exchange component.
266    /// When the exchange component exceeds this many lines, preflight automatically
267    /// runs compact before computing the diff. Set to 0 or omit to disable.
268    #[serde(
269        default,
270        skip_serializing_if = "Option::is_none",
271        rename = "agent_doc_auto_compact"
272    )]
273    pub auto_compact: Option<usize>,
274    /// Document-level lifecycle hooks: shell commands executed at key events.
275    ///
276    /// Supported events: `session_start`, `post_write`, `post_commit`.
277    /// Each event maps to a list of shell commands (run via `sh -c`).
278    ///
279    /// Template variables substituted before execution:
280    /// - `{{session_id}}` — document session UUID
281    /// - `{{file}}` — document file path
282    /// - `{{agent}}` — agent name (or empty string)
283    /// - `{{model}}` — model name (or empty string)
284    ///
285    /// Execution is best-effort: failures log to stderr and never block the session.
286    ///
287    /// Example:
288    /// ```yaml
289    /// hooks:
290    ///   session_start:
291    ///     - "curl -s -X POST https://api.example.com/sessions -d '{\"session_id\": \"{{session_id}}\"}'"
292    ///   post_write:
293    ///     - "notify-send 'agent-doc' 'Response written to {{file}}'"
294    /// ```
295    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
296    pub hooks: std::collections::HashMap<String, Vec<String>>,
297}
298
299impl Frontmatter {
300    /// Resolve the canonical (format, write) pair from all three fields.
301    ///
302    /// Priority:
303    /// 1. Explicit `agent_doc_format` / `agent_doc_write` fields (highest)
304    /// 2. Deprecated `agent_doc_mode` field (auto-migrated)
305    /// 3. Defaults: format=template, write=crdt
306    pub fn resolve_mode(&self) -> ResolvedMode {
307        // Start with defaults
308        let mut format = AgentDocFormat::Template;
309        let mut write = AgentDocWrite::Crdt;
310
311        // Apply deprecated mode if present (lowest priority)
312        if let Some(ref mode_str) = self.mode {
313            match mode_str.as_str() {
314                "append" => {
315                    format = AgentDocFormat::Append;
316                    // write stays crdt (user preference: always crdt)
317                }
318                "template" => {
319                    format = AgentDocFormat::Template;
320                    // write stays crdt
321                }
322                "stream" => {
323                    format = AgentDocFormat::Template;
324                    write = AgentDocWrite::Crdt;
325                }
326                _ => {} // unknown mode, use defaults
327            }
328        }
329
330        // Override with explicit new fields (highest priority)
331        if let Some(f) = self.format {
332            format = f;
333        }
334        if let Some(w) = self.write_mode {
335            write = w;
336        }
337
338        ResolvedMode { format, write }
339    }
340}
341
342/// Parse YAML frontmatter from a document. Returns (frontmatter, body).
343/// If no frontmatter block is present, returns defaults and the full content as body.
344pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
345    if !content.starts_with("---\n") {
346        return Ok((Frontmatter::default(), content));
347    }
348    let rest = &content[4..]; // skip opening ---\n
349    let end = rest
350        .find("\n---\n")
351        .or_else(|| rest.find("\n---"))
352        .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
353    let yaml = &rest[..end];
354    let fm: Frontmatter = serde_yaml::from_str(yaml)?;
355    let body_start = 4 + end + 4; // opening --- + yaml + closing ---\n
356    let body = if body_start <= content.len() {
357        &content[body_start..]
358    } else {
359        ""
360    };
361    Ok((fm, body))
362}
363
364/// Write frontmatter back into a document, preserving the body.
365pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
366    let yaml = serde_yaml::to_string(fm)?;
367    Ok(format!("---\n{}---\n{}", yaml, body))
368}
369
370/// Update the session ID in a document string. Creates frontmatter if missing.
371pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
372    let (mut fm, body) = parse(content)?;
373    fm.session = Some(session_id.to_string());
374    write(&fm, body)
375}
376
377/// Update the resume (agent conversation) ID in a document string.
378pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
379    let (mut fm, body) = parse(content)?;
380    fm.resume = Some(resume_id.to_string());
381    write(&fm, body)
382}
383
384/// Set both agent_doc_format and agent_doc_write, clearing deprecated agent_doc_mode.
385pub fn set_format_and_write(
386    content: &str,
387    format: AgentDocFormat,
388    write_mode: AgentDocWrite,
389) -> Result<String> {
390    let (mut fm, body) = parse(content)?;
391    fm.format = Some(format);
392    fm.write_mode = Some(write_mode);
393    fm.mode = None;
394    write(&fm, body)
395}
396
397/// Merge YAML key/value pairs into a document's frontmatter.
398///
399/// Takes a YAML string of fields to merge (additive — never removes keys).
400/// Only known frontmatter fields are applied; unknown keys are ignored.
401/// Returns the updated document content.
402pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
403    let (mut fm, body) = parse(content)?;
404    let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
405        .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
406    let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
407
408    for (key, value) in &mapping {
409        let key_str = key.as_str().unwrap_or("");
410        let val_str = || value.as_str().map(|s| s.to_string());
411        match key_str {
412            "agent_doc_session" | "session" => fm.session = val_str(),
413            "resume" => fm.resume = val_str(),
414            "agent" => fm.agent = val_str(),
415            "model" => fm.model = val_str(),
416            "branch" => fm.branch = val_str(),
417            "tmux_session" => fm.tmux_session = val_str(),
418            "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
419            "agent_doc_format" => {
420                if let Some(s) = value.as_str()
421                    && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
422                {
423                    fm.format = Some(f);
424                }
425            }
426            "agent_doc_write" => {
427                if let Some(s) = value.as_str()
428                    && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
429                {
430                    fm.write_mode = Some(w);
431                }
432            }
433            "claude_args" => fm.claude_args = val_str(),
434            _ => {
435                eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
436            }
437        }
438    }
439
440    write(&fm, body)
441}
442
443/// Update the tmux_session name in a document string.
444///
445/// **Deprecated.** `tmux_session` in frontmatter is deprecated — session is now
446/// determined at runtime. This function is retained for backward compatibility
447/// (claim and sync still write it so older binaries can read it).
448pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
449    let (mut fm, body) = parse(content)?;
450    fm.tmux_session = Some(session_name.to_string());
451    write(&fm, body)
452}
453
454/// Ensure the document has a session ID. If no frontmatter exists, creates one
455/// with a new UUID v4. If frontmatter exists but session is None/null, generates
456/// a UUID and sets it. If session already exists, returns as-is.
457/// Returns (updated_content, session_id).
458pub fn ensure_session(content: &str) -> Result<(String, String)> {
459    let (fm, _body) = parse(content)?;
460    if let Some(ref session_id) = fm.session {
461        // Session already set — return content unchanged
462        return Ok((content.to_string(), session_id.clone()));
463    }
464    let session_id = Uuid::new_v4().to_string();
465    let updated = set_session_id(content, &session_id)?;
466    Ok((updated, session_id))
467}
468
469/// Read the session UUID from a document file. Returns empty string if not found.
470pub fn read_session_id(file: &std::path::Path) -> Option<String> {
471    let content = std::fs::read_to_string(file).ok()?;
472    let (fm, _) = parse(&content).ok()?;
473    fm.session
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn parse_no_frontmatter() {
482        let content = "# Hello\n\nBody text.\n";
483        let (fm, body) = parse(content).unwrap();
484        assert!(fm.session.is_none());
485        assert!(fm.agent.is_none());
486        assert!(fm.model.is_none());
487        assert!(fm.branch.is_none());
488        assert_eq!(body, content);
489    }
490
491    #[test]
492    fn parse_all_fields() {
493        let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
494        let (fm, body) = parse(content).unwrap();
495        assert_eq!(fm.session.as_deref(), Some("abc-123"));
496        assert_eq!(fm.agent.as_deref(), Some("claude"));
497        assert_eq!(fm.model.as_deref(), Some("opus"));
498        assert_eq!(fm.branch.as_deref(), Some("main"));
499        assert!(body.contains("Body"));
500    }
501
502    #[test]
503    fn parse_partial_fields() {
504        let content = "---\nsession: xyz\n---\n# Doc\n";
505        let (fm, body) = parse(content).unwrap();
506        assert_eq!(fm.session.as_deref(), Some("xyz"));
507        assert!(fm.agent.is_none());
508        assert!(body.contains("# Doc"));
509    }
510
511    #[test]
512    fn parse_null_fields() {
513        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
514        let (fm, body) = parse(content).unwrap();
515        assert!(fm.session.is_none());
516        assert!(fm.agent.is_none());
517        assert!(fm.model.is_none());
518        assert!(fm.branch.is_none());
519        assert!(body.contains("Body"));
520    }
521
522    #[test]
523    fn parse_unterminated_frontmatter() {
524        let content = "---\nsession: abc\nno closing block";
525        let err = parse(content).unwrap_err();
526        assert!(err.to_string().contains("Unterminated frontmatter"));
527    }
528
529    #[test]
530    fn parse_closing_at_eof() {
531        let content = "---\nsession: abc\n---";
532        let (fm, body) = parse(content).unwrap();
533        assert_eq!(fm.session.as_deref(), Some("abc"));
534        assert_eq!(body, "");
535    }
536
537    #[test]
538    fn parse_empty_body() {
539        let content = "---\nsession: abc\n---\n";
540        let (fm, _body) = parse(content).unwrap();
541        assert_eq!(fm.session.as_deref(), Some("abc"));
542    }
543
544    #[test]
545    fn write_roundtrip() {
546        // Start from write output to ensure consistent formatting
547        let fm = Frontmatter {
548            session: Some("test-id".to_string()),
549            resume: Some("resume-id".to_string()),
550            agent: Some("claude".to_string()),
551            model: Some("opus".to_string()),
552            branch: Some("dev".to_string()),
553            tmux_session: None,
554            mode: None,
555            format: None,
556            write_mode: None,
557            stream_config: None,
558            claude_args: None,
559            debounce_ms: None,
560            links: vec![],
561            auto_compact: None,
562            hooks: std::collections::HashMap::new(),
563        };
564        let body = "# Hello\n\nBody text.\n";
565        let written = write(&fm, body).unwrap();
566        let (fm2, body2) = parse(&written).unwrap();
567        assert_eq!(fm2.session, fm.session);
568        assert_eq!(fm2.agent, fm.agent);
569        assert_eq!(fm2.model, fm.model);
570        assert_eq!(fm2.branch, fm.branch);
571        // Roundtrip preserves body (may have leading newline from parse)
572        assert!(body2.contains("# Hello"));
573        assert!(body2.contains("Body text."));
574    }
575
576    #[test]
577    fn write_default_frontmatter() {
578        let fm = Frontmatter::default();
579        let result = write(&fm, "body\n").unwrap();
580        assert!(result.starts_with("---\n"));
581        assert!(result.ends_with("---\nbody\n"));
582    }
583
584    #[test]
585    fn write_preserves_body_content() {
586        let fm = Frontmatter::default();
587        let body = "# Title\n\nSome **markdown** with `code`.\n";
588        let result = write(&fm, body).unwrap();
589        assert!(result.contains("# Title"));
590        assert!(result.contains("Some **markdown** with `code`."));
591    }
592
593    #[test]
594    fn set_session_id_creates_frontmatter() {
595        let content = "# No frontmatter\n\nJust body.\n";
596        let result = set_session_id(content, "new-session").unwrap();
597        let (fm, body) = parse(&result).unwrap();
598        assert_eq!(fm.session.as_deref(), Some("new-session"));
599        assert!(body.contains("# No frontmatter"));
600    }
601
602    #[test]
603    fn set_session_id_updates_existing() {
604        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
605        let result = set_session_id(content, "new-id").unwrap();
606        let (fm, body) = parse(&result).unwrap();
607        assert_eq!(fm.session.as_deref(), Some("new-id"));
608        assert_eq!(fm.agent.as_deref(), Some("claude"));
609        assert!(body.contains("Body"));
610    }
611
612    #[test]
613    fn set_session_id_preserves_other_fields() {
614        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
615        let result = set_session_id(content, "new").unwrap();
616        let (fm, _) = parse(&result).unwrap();
617        assert_eq!(fm.session.as_deref(), Some("new"));
618        assert_eq!(fm.agent.as_deref(), Some("claude"));
619        assert_eq!(fm.model.as_deref(), Some("opus"));
620        assert_eq!(fm.branch.as_deref(), Some("dev"));
621    }
622
623    #[test]
624    fn ensure_session_no_frontmatter() {
625        let content = "# Hello\n\nBody.\n";
626        let (updated, sid) = ensure_session(content).unwrap();
627        // Should have generated a UUID
628        assert_eq!(sid.len(), 36); // UUID v4 string length
629        let (fm, body) = parse(&updated).unwrap();
630        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
631        assert!(body.contains("# Hello"));
632    }
633
634    #[test]
635    fn ensure_session_null_session() {
636        let content = "---\nsession:\nagent: claude\n---\nBody\n";
637        let (updated, sid) = ensure_session(content).unwrap();
638        assert_eq!(sid.len(), 36);
639        let (fm, body) = parse(&updated).unwrap();
640        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
641        assert_eq!(fm.agent.as_deref(), Some("claude"));
642        assert!(body.contains("Body"));
643    }
644
645    #[test]
646    fn ensure_session_existing_session() {
647        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
648        let (updated, sid) = ensure_session(content).unwrap();
649        assert_eq!(sid, "existing-id");
650        // Content should be unchanged
651        assert_eq!(updated, content);
652    }
653
654    #[test]
655    fn parse_legacy_session_field() {
656        // Old `session:` field should still parse via serde alias
657        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
658        let (fm, body) = parse(content).unwrap();
659        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
660        assert_eq!(fm.agent.as_deref(), Some("claude"));
661        assert!(body.contains("Body"));
662    }
663
664    #[test]
665    fn parse_agent_doc_mode_canonical() {
666        let content = "---\nagent_doc_mode: template\n---\nBody\n";
667        let (fm, _) = parse(content).unwrap();
668        assert_eq!(fm.mode.as_deref(), Some("template"));
669    }
670
671    #[test]
672    fn parse_mode_shorthand_alias() {
673        let content = "---\nmode: template\n---\nBody\n";
674        let (fm, _) = parse(content).unwrap();
675        assert_eq!(fm.mode.as_deref(), Some("template"));
676    }
677
678    #[test]
679    fn parse_response_mode_legacy_alias() {
680        let content = "---\nresponse_mode: template\n---\nBody\n";
681        let (fm, _) = parse(content).unwrap();
682        assert_eq!(fm.mode.as_deref(), Some("template"));
683    }
684
685    #[test]
686    fn write_uses_agent_doc_mode_field() {
687        #[allow(deprecated)]
688        let fm = Frontmatter {
689            mode: Some("template".to_string()),
690            ..Default::default()
691        };
692        let result = write(&fm, "body\n").unwrap();
693        assert!(result.contains("agent_doc_mode:"));
694        assert!(!result.contains("response_mode:"));
695        assert!(!result.contains("\nmode:"));
696    }
697
698    #[test]
699    fn write_uses_new_field_name() {
700        let fm = Frontmatter {
701            session: Some("test-id".to_string()),
702            ..Default::default()
703        };
704        let result = write(&fm, "body\n").unwrap();
705        assert!(result.contains("agent_doc_session:"));
706        assert!(!result.contains("\nsession:"));
707    }
708
709    // --- resolve_mode tests ---
710
711    #[test]
712    fn resolve_mode_defaults() {
713        let fm = Frontmatter::default();
714        let resolved = fm.resolve_mode();
715        assert_eq!(resolved.format, AgentDocFormat::Template);
716        assert_eq!(resolved.write, AgentDocWrite::Crdt);
717    }
718
719    #[test]
720    fn resolve_mode_from_deprecated_append() {
721        let content = "---\nagent_doc_mode: append\n---\nBody\n";
722        let (fm, _) = parse(content).unwrap();
723        let resolved = fm.resolve_mode();
724        assert_eq!(resolved.format, AgentDocFormat::Append);
725        assert_eq!(resolved.write, AgentDocWrite::Crdt);
726    }
727
728    #[test]
729    fn resolve_mode_from_deprecated_template() {
730        let content = "---\nagent_doc_mode: template\n---\nBody\n";
731        let (fm, _) = parse(content).unwrap();
732        let resolved = fm.resolve_mode();
733        assert_eq!(resolved.format, AgentDocFormat::Template);
734        assert_eq!(resolved.write, AgentDocWrite::Crdt);
735    }
736
737    #[test]
738    fn resolve_mode_from_deprecated_stream() {
739        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
740        let (fm, _) = parse(content).unwrap();
741        let resolved = fm.resolve_mode();
742        assert_eq!(resolved.format, AgentDocFormat::Template);
743        assert_eq!(resolved.write, AgentDocWrite::Crdt);
744    }
745
746    #[test]
747    fn resolve_mode_new_fields_override_deprecated() {
748        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
749        let (fm, _) = parse(content).unwrap();
750        let resolved = fm.resolve_mode();
751        assert_eq!(resolved.format, AgentDocFormat::Template);
752        assert_eq!(resolved.write, AgentDocWrite::Merge);
753    }
754
755    #[test]
756    fn resolve_mode_explicit_new_fields_only() {
757        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
758        let (fm, _) = parse(content).unwrap();
759        let resolved = fm.resolve_mode();
760        assert_eq!(resolved.format, AgentDocFormat::Append);
761        assert_eq!(resolved.write, AgentDocWrite::Crdt);
762    }
763
764    #[test]
765    fn resolve_mode_partial_new_field_format_only() {
766        let content = "---\nagent_doc_format: append\n---\nBody\n";
767        let (fm, _) = parse(content).unwrap();
768        let resolved = fm.resolve_mode();
769        assert_eq!(resolved.format, AgentDocFormat::Append);
770        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
771    }
772
773    #[test]
774    fn resolve_mode_partial_new_field_write_only() {
775        let content = "---\nagent_doc_write: merge\n---\nBody\n";
776        let (fm, _) = parse(content).unwrap();
777        let resolved = fm.resolve_mode();
778        assert_eq!(resolved.format, AgentDocFormat::Template); // default
779        assert_eq!(resolved.write, AgentDocWrite::Merge);
780    }
781
782    #[test]
783    fn resolve_mode_helper_methods() {
784        let fm = Frontmatter::default();
785        let resolved = fm.resolve_mode();
786        assert!(resolved.is_template());
787        assert!(!resolved.is_append());
788        assert!(resolved.is_crdt());
789    }
790
791    #[test]
792    fn parse_new_format_field() {
793        let content = "---\nagent_doc_format: template\n---\nBody\n";
794        let (fm, _) = parse(content).unwrap();
795        assert_eq!(fm.format, Some(AgentDocFormat::Template));
796    }
797
798    #[test]
799    fn parse_new_write_field() {
800        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
801        let (fm, _) = parse(content).unwrap();
802        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
803    }
804
805    #[test]
806    fn write_uses_new_format_write_fields() {
807        let fm = Frontmatter {
808            format: Some(AgentDocFormat::Template),
809            write_mode: Some(AgentDocWrite::Crdt),
810            ..Default::default()
811        };
812        let result = write(&fm, "body\n").unwrap();
813        assert!(result.contains("agent_doc_format:"));
814        assert!(result.contains("agent_doc_write:"));
815        assert!(!result.contains("agent_doc_mode:"));
816    }
817
818    #[test]
819    fn set_format_and_write_clears_deprecated_mode() {
820        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
821        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
822        let (fm, _) = parse(&result).unwrap();
823        assert!(fm.mode.is_none());
824        assert_eq!(fm.format, Some(AgentDocFormat::Template));
825        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
826    }
827
828    // --- merge_fields tests ---
829
830    #[test]
831    fn merge_fields_adds_new_field() {
832        let content = "---\nagent_doc_session: abc\n---\nBody\n";
833        let result = merge_fields(content, "model: opus").unwrap();
834        let (fm, body) = parse(&result).unwrap();
835        assert_eq!(fm.session.as_deref(), Some("abc"));
836        assert_eq!(fm.model.as_deref(), Some("opus"));
837        assert!(body.contains("Body"));
838    }
839
840    #[test]
841    fn merge_fields_updates_existing_field() {
842        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
843        let result = merge_fields(content, "model: opus").unwrap();
844        let (fm, _) = parse(&result).unwrap();
845        assert_eq!(fm.model.as_deref(), Some("opus"));
846        assert_eq!(fm.session.as_deref(), Some("abc"));
847    }
848
849    #[test]
850    fn merge_fields_multiple_fields() {
851        let content = "---\nagent_doc_session: abc\n---\nBody\n";
852        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
853        let (fm, _) = parse(&result).unwrap();
854        assert_eq!(fm.model.as_deref(), Some("opus"));
855        assert_eq!(fm.agent.as_deref(), Some("claude"));
856        assert_eq!(fm.branch.as_deref(), Some("main"));
857    }
858
859    #[test]
860    fn merge_fields_format_enum() {
861        let content = "---\nagent_doc_session: abc\n---\nBody\n";
862        let result = merge_fields(content, "agent_doc_format: append").unwrap();
863        let (fm, _) = parse(&result).unwrap();
864        assert_eq!(fm.format, Some(AgentDocFormat::Append));
865    }
866
867    #[test]
868    fn merge_fields_write_enum() {
869        let content = "---\nagent_doc_session: abc\n---\nBody\n";
870        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
871        let (fm, _) = parse(&result).unwrap();
872        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
873    }
874
875    #[test]
876    fn merge_fields_ignores_unknown() {
877        let content = "---\nagent_doc_session: abc\n---\nBody\n";
878        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
879        let (fm, _) = parse(&result).unwrap();
880        assert_eq!(fm.model.as_deref(), Some("opus"));
881    }
882
883    #[test]
884    fn merge_fields_preserves_body() {
885        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
886        let result = merge_fields(content, "model: opus").unwrap();
887        assert!(result.contains("# Title"));
888        assert!(result.contains("Some **markdown** content."));
889    }
890
891    #[test]
892    fn set_format_and_write_clears_deprecated() {
893        let content = "---\nagent_doc_mode: append\n---\nBody\n";
894        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
895        let (fm, _) = parse(&result).unwrap();
896        assert!(fm.mode.is_none());
897        assert_eq!(fm.format, Some(AgentDocFormat::Template));
898        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
899    }
900
901    #[test]
902    fn hooks_roundtrip() {
903        let content = "---\nhooks:\n  session_start:\n    - \"echo start {{session_id}}\"\n  post_write:\n    - \"notify {{file}}\"\n---\nBody\n";
904        let (fm, _) = parse(content).unwrap();
905        assert_eq!(fm.hooks.get("session_start"), Some(&vec!["echo start {{session_id}}".to_string()]));
906        assert_eq!(fm.hooks.get("post_write"), Some(&vec!["notify {{file}}".to_string()]));
907    }
908
909    #[test]
910    fn hooks_omitted_when_empty() {
911        let fm = Frontmatter::default();
912        let result = write(&fm, "body\n").unwrap();
913        assert!(!result.contains("hooks"));
914    }
915
916    #[test]
917    fn hooks_absent_parses_as_empty() {
918        let content = "---\nsession: abc\n---\nBody\n";
919        let (fm, _) = parse(content).unwrap();
920        assert!(fm.hooks.is_empty());
921    }
922}