Skip to main content

agent_doc/
frontmatter.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use uuid::Uuid;
5
6/// Document format: controls document structure.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
8pub enum AgentDocFormat {
9    /// Alternating ## User / ## Assistant blocks (also known as "inline")
10    #[clap(alias = "inline")]
11    Append,
12    /// In-place component patching with <!-- agent:name --> markers
13    Template,
14}
15
16impl fmt::Display for AgentDocFormat {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Append => write!(f, "inline"),
20            Self::Template => write!(f, "template"),
21        }
22    }
23}
24
25impl Serialize for AgentDocFormat {
26    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
27    where
28        S: serde::Serializer,
29    {
30        match self {
31            Self::Append => serializer.serialize_str("inline"),
32            Self::Template => serializer.serialize_str("template"),
33        }
34    }
35}
36
37impl<'de> Deserialize<'de> for AgentDocFormat {
38    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
39    where
40        D: serde::Deserializer<'de>,
41    {
42        let s = String::deserialize(deserializer)?;
43        match s.as_str() {
44            "append" | "inline" => Ok(Self::Append),
45            "template" => Ok(Self::Template),
46            other => Err(serde::de::Error::unknown_variant(other, &["inline", "append", "template"])),
47        }
48    }
49}
50
51/// Write strategy: controls how responses are merged into the document.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
53#[serde(rename_all = "lowercase")]
54pub enum AgentDocWrite {
55    /// 3-way merge via git merge-file
56    Merge,
57    /// CRDT-based conflict-free merge (yrs)
58    Crdt,
59}
60
61impl fmt::Display for AgentDocWrite {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Merge => write!(f, "merge"),
65            Self::Crdt => write!(f, "crdt"),
66        }
67    }
68}
69
70/// Resolved mode pair — the canonical representation after deprecation migration.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct ResolvedMode {
73    pub format: AgentDocFormat,
74    pub write: AgentDocWrite,
75}
76
77impl ResolvedMode {
78    pub fn is_template(&self) -> bool {
79        self.format == AgentDocFormat::Template
80    }
81
82    pub fn is_append(&self) -> bool {
83        self.format == AgentDocFormat::Append
84    }
85
86    pub fn is_crdt(&self) -> bool {
87        self.write == AgentDocWrite::Crdt
88    }
89}
90
91/// Configuration for stream mode (real-time CRDT write-back).
92#[derive(Debug, Default, Clone, Serialize, Deserialize)]
93pub struct StreamConfig {
94    /// Write-back interval in milliseconds (default: 200)
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub interval: Option<u64>,
97    /// Strip ANSI escape codes from agent output (default: true)
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub strip_ansi: Option<bool>,
100    /// Target component name for stream output (default: "exchange")
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub target: Option<String>,
103    /// Include chain-of-thought (thinking) blocks in output (default: false)
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub thinking: Option<bool>,
106    /// Route thinking to a separate component (e.g., "log"). If unset, thinking
107    /// is interleaved with response text in the target component.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub thinking_target: Option<String>,
110}
111
112#[derive(Debug, Default, Serialize, Deserialize)]
113pub struct Frontmatter {
114    /// Document/routing UUID — permanent identifier for tmux pane routing.
115    /// Serialized as `agent_doc_session` in YAML; reads legacy `session` via alias.
116    #[serde(
117        default,
118        skip_serializing_if = "Option::is_none",
119        rename = "agent_doc_session",
120        alias = "session"
121    )]
122    pub session: Option<String>,
123    /// Agent conversation ID — used for `--resume` with agent backends.
124    /// Separate from `session` so the routing key never changes.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub resume: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub agent: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub model: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub branch: Option<String>,
133    /// Tmux session name for pane affinity (e.g., "claude").
134    /// Set by `claim` or `sync` on first use; used to keep panes in the same session.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub tmux_session: Option<String>,
137    /// **Deprecated.** Use `agent_doc_format` + `agent_doc_write` instead.
138    /// Kept for backward compatibility. Values: "append", "template", "stream".
139    /// Serialized as `agent_doc_mode` in YAML; reads legacy `response_mode` and shorthand `mode` via aliases.
140    #[serde(
141        default,
142        skip_serializing_if = "Option::is_none",
143        rename = "agent_doc_mode",
144        alias = "mode",
145        alias = "response_mode"
146    )]
147    pub mode: Option<String>,
148    /// Document format: controls document structure (append | template).
149    #[serde(
150        default,
151        skip_serializing_if = "Option::is_none",
152        rename = "agent_doc_format"
153    )]
154    pub format: Option<AgentDocFormat>,
155    /// Write strategy: controls merge behavior (merge | crdt).
156    #[serde(
157        default,
158        skip_serializing_if = "Option::is_none",
159        rename = "agent_doc_write"
160    )]
161    pub write_mode: Option<AgentDocWrite>,
162    /// Stream mode configuration (used when write strategy is CRDT).
163    #[serde(
164        default,
165        skip_serializing_if = "Option::is_none",
166        rename = "agent_doc_stream"
167    )]
168    pub stream_config: Option<StreamConfig>,
169    /// Additional CLI arguments to pass to the `claude` process.
170    /// Space-separated string (e.g., "--dangerously-skip-permissions").
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub claude_args: Option<String>,
173}
174
175impl Frontmatter {
176    /// Resolve the canonical (format, write) pair from all three fields.
177    ///
178    /// Priority:
179    /// 1. Explicit `agent_doc_format` / `agent_doc_write` fields (highest)
180    /// 2. Deprecated `agent_doc_mode` field (auto-migrated)
181    /// 3. Defaults: format=template, write=crdt
182    pub fn resolve_mode(&self) -> ResolvedMode {
183        // Start with defaults
184        let mut format = AgentDocFormat::Template;
185        let mut write = AgentDocWrite::Crdt;
186
187        // Apply deprecated mode if present (lowest priority)
188        if let Some(ref mode_str) = self.mode {
189            match mode_str.as_str() {
190                "append" => {
191                    format = AgentDocFormat::Append;
192                    // write stays crdt (user preference: always crdt)
193                }
194                "template" => {
195                    format = AgentDocFormat::Template;
196                    // write stays crdt
197                }
198                "stream" => {
199                    format = AgentDocFormat::Template;
200                    write = AgentDocWrite::Crdt;
201                }
202                _ => {} // unknown mode, use defaults
203            }
204        }
205
206        // Override with explicit new fields (highest priority)
207        if let Some(f) = self.format {
208            format = f;
209        }
210        if let Some(w) = self.write_mode {
211            write = w;
212        }
213
214        ResolvedMode { format, write }
215    }
216}
217
218/// Parse YAML frontmatter from a document. Returns (frontmatter, body).
219/// If no frontmatter block is present, returns defaults and the full content as body.
220pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
221    if !content.starts_with("---\n") {
222        return Ok((Frontmatter::default(), content));
223    }
224    let rest = &content[4..]; // skip opening ---\n
225    let end = rest
226        .find("\n---\n")
227        .or_else(|| rest.find("\n---"))
228        .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
229    let yaml = &rest[..end];
230    let fm: Frontmatter = serde_yaml::from_str(yaml)?;
231    let body_start = 4 + end + 4; // opening --- + yaml + closing ---\n
232    let body = if body_start <= content.len() {
233        &content[body_start..]
234    } else {
235        ""
236    };
237    Ok((fm, body))
238}
239
240/// Write frontmatter back into a document, preserving the body.
241pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
242    let yaml = serde_yaml::to_string(fm)?;
243    Ok(format!("---\n{}---\n{}", yaml, body))
244}
245
246/// Update the session ID in a document string. Creates frontmatter if missing.
247pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
248    let (mut fm, body) = parse(content)?;
249    fm.session = Some(session_id.to_string());
250    write(&fm, body)
251}
252
253/// Update the resume (agent conversation) ID in a document string.
254pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
255    let (mut fm, body) = parse(content)?;
256    fm.resume = Some(resume_id.to_string());
257    write(&fm, body)
258}
259
260/// Set both agent_doc_format and agent_doc_write, clearing deprecated agent_doc_mode.
261pub fn set_format_and_write(
262    content: &str,
263    format: AgentDocFormat,
264    write_mode: AgentDocWrite,
265) -> Result<String> {
266    let (mut fm, body) = parse(content)?;
267    fm.format = Some(format);
268    fm.write_mode = Some(write_mode);
269    fm.mode = None;
270    write(&fm, body)
271}
272
273/// Merge YAML key/value pairs into a document's frontmatter.
274///
275/// Takes a YAML string of fields to merge (additive — never removes keys).
276/// Only known frontmatter fields are applied; unknown keys are ignored.
277/// Returns the updated document content.
278pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
279    let (mut fm, body) = parse(content)?;
280    let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
281        .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
282    let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
283
284    for (key, value) in &mapping {
285        let key_str = key.as_str().unwrap_or("");
286        let val_str = || value.as_str().map(|s| s.to_string());
287        match key_str {
288            "agent_doc_session" | "session" => fm.session = val_str(),
289            "resume" => fm.resume = val_str(),
290            "agent" => fm.agent = val_str(),
291            "model" => fm.model = val_str(),
292            "branch" => fm.branch = val_str(),
293            "tmux_session" => fm.tmux_session = val_str(),
294            "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
295            "agent_doc_format" => {
296                if let Some(s) = value.as_str()
297                    && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
298                {
299                    fm.format = Some(f);
300                }
301            }
302            "agent_doc_write" => {
303                if let Some(s) = value.as_str()
304                    && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
305                {
306                    fm.write_mode = Some(w);
307                }
308            }
309            "claude_args" => fm.claude_args = val_str(),
310            _ => {
311                eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
312            }
313        }
314    }
315
316    write(&fm, body)
317}
318
319/// Update the tmux_session name in a document string.
320pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
321    let (mut fm, body) = parse(content)?;
322    fm.tmux_session = Some(session_name.to_string());
323    write(&fm, body)
324}
325
326/// Ensure the document has a session ID. If no frontmatter exists, creates one
327/// with a new UUID v4. If frontmatter exists but session is None/null, generates
328/// a UUID and sets it. If session already exists, returns as-is.
329/// Returns (updated_content, session_id).
330pub fn ensure_session(content: &str) -> Result<(String, String)> {
331    let (fm, _body) = parse(content)?;
332    if let Some(ref session_id) = fm.session {
333        // Session already set — return content unchanged
334        return Ok((content.to_string(), session_id.clone()));
335    }
336    let session_id = Uuid::new_v4().to_string();
337    let updated = set_session_id(content, &session_id)?;
338    Ok((updated, session_id))
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn parse_no_frontmatter() {
347        let content = "# Hello\n\nBody text.\n";
348        let (fm, body) = parse(content).unwrap();
349        assert!(fm.session.is_none());
350        assert!(fm.agent.is_none());
351        assert!(fm.model.is_none());
352        assert!(fm.branch.is_none());
353        assert_eq!(body, content);
354    }
355
356    #[test]
357    fn parse_all_fields() {
358        let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
359        let (fm, body) = parse(content).unwrap();
360        assert_eq!(fm.session.as_deref(), Some("abc-123"));
361        assert_eq!(fm.agent.as_deref(), Some("claude"));
362        assert_eq!(fm.model.as_deref(), Some("opus"));
363        assert_eq!(fm.branch.as_deref(), Some("main"));
364        assert!(body.contains("Body"));
365    }
366
367    #[test]
368    fn parse_partial_fields() {
369        let content = "---\nsession: xyz\n---\n# Doc\n";
370        let (fm, body) = parse(content).unwrap();
371        assert_eq!(fm.session.as_deref(), Some("xyz"));
372        assert!(fm.agent.is_none());
373        assert!(body.contains("# Doc"));
374    }
375
376    #[test]
377    fn parse_null_fields() {
378        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
379        let (fm, body) = parse(content).unwrap();
380        assert!(fm.session.is_none());
381        assert!(fm.agent.is_none());
382        assert!(fm.model.is_none());
383        assert!(fm.branch.is_none());
384        assert!(body.contains("Body"));
385    }
386
387    #[test]
388    fn parse_unterminated_frontmatter() {
389        let content = "---\nsession: abc\nno closing block";
390        let err = parse(content).unwrap_err();
391        assert!(err.to_string().contains("Unterminated frontmatter"));
392    }
393
394    #[test]
395    fn parse_closing_at_eof() {
396        let content = "---\nsession: abc\n---";
397        let (fm, body) = parse(content).unwrap();
398        assert_eq!(fm.session.as_deref(), Some("abc"));
399        assert_eq!(body, "");
400    }
401
402    #[test]
403    fn parse_empty_body() {
404        let content = "---\nsession: abc\n---\n";
405        let (fm, _body) = parse(content).unwrap();
406        assert_eq!(fm.session.as_deref(), Some("abc"));
407    }
408
409    #[test]
410    fn write_roundtrip() {
411        // Start from write output to ensure consistent formatting
412        let fm = Frontmatter {
413            session: Some("test-id".to_string()),
414            resume: Some("resume-id".to_string()),
415            agent: Some("claude".to_string()),
416            model: Some("opus".to_string()),
417            branch: Some("dev".to_string()),
418            tmux_session: None,
419            mode: None,
420            format: None,
421            write_mode: None,
422            stream_config: None,
423            claude_args: None,
424        };
425        let body = "# Hello\n\nBody text.\n";
426        let written = write(&fm, body).unwrap();
427        let (fm2, body2) = parse(&written).unwrap();
428        assert_eq!(fm2.session, fm.session);
429        assert_eq!(fm2.agent, fm.agent);
430        assert_eq!(fm2.model, fm.model);
431        assert_eq!(fm2.branch, fm.branch);
432        // Roundtrip preserves body (may have leading newline from parse)
433        assert!(body2.contains("# Hello"));
434        assert!(body2.contains("Body text."));
435    }
436
437    #[test]
438    fn write_default_frontmatter() {
439        let fm = Frontmatter::default();
440        let result = write(&fm, "body\n").unwrap();
441        assert!(result.starts_with("---\n"));
442        assert!(result.ends_with("---\nbody\n"));
443    }
444
445    #[test]
446    fn write_preserves_body_content() {
447        let fm = Frontmatter::default();
448        let body = "# Title\n\nSome **markdown** with `code`.\n";
449        let result = write(&fm, body).unwrap();
450        assert!(result.contains("# Title"));
451        assert!(result.contains("Some **markdown** with `code`."));
452    }
453
454    #[test]
455    fn set_session_id_creates_frontmatter() {
456        let content = "# No frontmatter\n\nJust body.\n";
457        let result = set_session_id(content, "new-session").unwrap();
458        let (fm, body) = parse(&result).unwrap();
459        assert_eq!(fm.session.as_deref(), Some("new-session"));
460        assert!(body.contains("# No frontmatter"));
461    }
462
463    #[test]
464    fn set_session_id_updates_existing() {
465        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
466        let result = set_session_id(content, "new-id").unwrap();
467        let (fm, body) = parse(&result).unwrap();
468        assert_eq!(fm.session.as_deref(), Some("new-id"));
469        assert_eq!(fm.agent.as_deref(), Some("claude"));
470        assert!(body.contains("Body"));
471    }
472
473    #[test]
474    fn set_session_id_preserves_other_fields() {
475        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
476        let result = set_session_id(content, "new").unwrap();
477        let (fm, _) = parse(&result).unwrap();
478        assert_eq!(fm.session.as_deref(), Some("new"));
479        assert_eq!(fm.agent.as_deref(), Some("claude"));
480        assert_eq!(fm.model.as_deref(), Some("opus"));
481        assert_eq!(fm.branch.as_deref(), Some("dev"));
482    }
483
484    #[test]
485    fn ensure_session_no_frontmatter() {
486        let content = "# Hello\n\nBody.\n";
487        let (updated, sid) = ensure_session(content).unwrap();
488        // Should have generated a UUID
489        assert_eq!(sid.len(), 36); // UUID v4 string length
490        let (fm, body) = parse(&updated).unwrap();
491        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
492        assert!(body.contains("# Hello"));
493    }
494
495    #[test]
496    fn ensure_session_null_session() {
497        let content = "---\nsession:\nagent: claude\n---\nBody\n";
498        let (updated, sid) = ensure_session(content).unwrap();
499        assert_eq!(sid.len(), 36);
500        let (fm, body) = parse(&updated).unwrap();
501        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
502        assert_eq!(fm.agent.as_deref(), Some("claude"));
503        assert!(body.contains("Body"));
504    }
505
506    #[test]
507    fn ensure_session_existing_session() {
508        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
509        let (updated, sid) = ensure_session(content).unwrap();
510        assert_eq!(sid, "existing-id");
511        // Content should be unchanged
512        assert_eq!(updated, content);
513    }
514
515    #[test]
516    fn parse_legacy_session_field() {
517        // Old `session:` field should still parse via serde alias
518        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
519        let (fm, body) = parse(content).unwrap();
520        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
521        assert_eq!(fm.agent.as_deref(), Some("claude"));
522        assert!(body.contains("Body"));
523    }
524
525    #[test]
526    fn parse_agent_doc_mode_canonical() {
527        let content = "---\nagent_doc_mode: template\n---\nBody\n";
528        let (fm, _) = parse(content).unwrap();
529        assert_eq!(fm.mode.as_deref(), Some("template"));
530    }
531
532    #[test]
533    fn parse_mode_shorthand_alias() {
534        let content = "---\nmode: template\n---\nBody\n";
535        let (fm, _) = parse(content).unwrap();
536        assert_eq!(fm.mode.as_deref(), Some("template"));
537    }
538
539    #[test]
540    fn parse_response_mode_legacy_alias() {
541        let content = "---\nresponse_mode: template\n---\nBody\n";
542        let (fm, _) = parse(content).unwrap();
543        assert_eq!(fm.mode.as_deref(), Some("template"));
544    }
545
546    #[test]
547    fn write_uses_agent_doc_mode_field() {
548        #[allow(deprecated)]
549        let fm = Frontmatter {
550            mode: Some("template".to_string()),
551            ..Default::default()
552        };
553        let result = write(&fm, "body\n").unwrap();
554        assert!(result.contains("agent_doc_mode:"));
555        assert!(!result.contains("response_mode:"));
556        assert!(!result.contains("\nmode:"));
557    }
558
559    #[test]
560    fn write_uses_new_field_name() {
561        let fm = Frontmatter {
562            session: Some("test-id".to_string()),
563            ..Default::default()
564        };
565        let result = write(&fm, "body\n").unwrap();
566        assert!(result.contains("agent_doc_session:"));
567        assert!(!result.contains("\nsession:"));
568    }
569
570    // --- resolve_mode tests ---
571
572    #[test]
573    fn resolve_mode_defaults() {
574        let fm = Frontmatter::default();
575        let resolved = fm.resolve_mode();
576        assert_eq!(resolved.format, AgentDocFormat::Template);
577        assert_eq!(resolved.write, AgentDocWrite::Crdt);
578    }
579
580    #[test]
581    fn resolve_mode_from_deprecated_append() {
582        let content = "---\nagent_doc_mode: append\n---\nBody\n";
583        let (fm, _) = parse(content).unwrap();
584        let resolved = fm.resolve_mode();
585        assert_eq!(resolved.format, AgentDocFormat::Append);
586        assert_eq!(resolved.write, AgentDocWrite::Crdt);
587    }
588
589    #[test]
590    fn resolve_mode_from_deprecated_template() {
591        let content = "---\nagent_doc_mode: template\n---\nBody\n";
592        let (fm, _) = parse(content).unwrap();
593        let resolved = fm.resolve_mode();
594        assert_eq!(resolved.format, AgentDocFormat::Template);
595        assert_eq!(resolved.write, AgentDocWrite::Crdt);
596    }
597
598    #[test]
599    fn resolve_mode_from_deprecated_stream() {
600        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
601        let (fm, _) = parse(content).unwrap();
602        let resolved = fm.resolve_mode();
603        assert_eq!(resolved.format, AgentDocFormat::Template);
604        assert_eq!(resolved.write, AgentDocWrite::Crdt);
605    }
606
607    #[test]
608    fn resolve_mode_new_fields_override_deprecated() {
609        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
610        let (fm, _) = parse(content).unwrap();
611        let resolved = fm.resolve_mode();
612        assert_eq!(resolved.format, AgentDocFormat::Template);
613        assert_eq!(resolved.write, AgentDocWrite::Merge);
614    }
615
616    #[test]
617    fn resolve_mode_explicit_new_fields_only() {
618        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
619        let (fm, _) = parse(content).unwrap();
620        let resolved = fm.resolve_mode();
621        assert_eq!(resolved.format, AgentDocFormat::Append);
622        assert_eq!(resolved.write, AgentDocWrite::Crdt);
623    }
624
625    #[test]
626    fn resolve_mode_partial_new_field_format_only() {
627        let content = "---\nagent_doc_format: append\n---\nBody\n";
628        let (fm, _) = parse(content).unwrap();
629        let resolved = fm.resolve_mode();
630        assert_eq!(resolved.format, AgentDocFormat::Append);
631        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
632    }
633
634    #[test]
635    fn resolve_mode_partial_new_field_write_only() {
636        let content = "---\nagent_doc_write: merge\n---\nBody\n";
637        let (fm, _) = parse(content).unwrap();
638        let resolved = fm.resolve_mode();
639        assert_eq!(resolved.format, AgentDocFormat::Template); // default
640        assert_eq!(resolved.write, AgentDocWrite::Merge);
641    }
642
643    #[test]
644    fn resolve_mode_helper_methods() {
645        let fm = Frontmatter::default();
646        let resolved = fm.resolve_mode();
647        assert!(resolved.is_template());
648        assert!(!resolved.is_append());
649        assert!(resolved.is_crdt());
650    }
651
652    #[test]
653    fn parse_new_format_field() {
654        let content = "---\nagent_doc_format: template\n---\nBody\n";
655        let (fm, _) = parse(content).unwrap();
656        assert_eq!(fm.format, Some(AgentDocFormat::Template));
657    }
658
659    #[test]
660    fn parse_new_write_field() {
661        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
662        let (fm, _) = parse(content).unwrap();
663        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
664    }
665
666    #[test]
667    fn write_uses_new_format_write_fields() {
668        let fm = Frontmatter {
669            format: Some(AgentDocFormat::Template),
670            write_mode: Some(AgentDocWrite::Crdt),
671            ..Default::default()
672        };
673        let result = write(&fm, "body\n").unwrap();
674        assert!(result.contains("agent_doc_format:"));
675        assert!(result.contains("agent_doc_write:"));
676        assert!(!result.contains("agent_doc_mode:"));
677    }
678
679    #[test]
680    fn set_format_and_write_clears_deprecated_mode() {
681        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
682        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
683        let (fm, _) = parse(&result).unwrap();
684        assert!(fm.mode.is_none());
685        assert_eq!(fm.format, Some(AgentDocFormat::Template));
686        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
687    }
688
689    // --- merge_fields tests ---
690
691    #[test]
692    fn merge_fields_adds_new_field() {
693        let content = "---\nagent_doc_session: abc\n---\nBody\n";
694        let result = merge_fields(content, "model: opus").unwrap();
695        let (fm, body) = parse(&result).unwrap();
696        assert_eq!(fm.session.as_deref(), Some("abc"));
697        assert_eq!(fm.model.as_deref(), Some("opus"));
698        assert!(body.contains("Body"));
699    }
700
701    #[test]
702    fn merge_fields_updates_existing_field() {
703        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
704        let result = merge_fields(content, "model: opus").unwrap();
705        let (fm, _) = parse(&result).unwrap();
706        assert_eq!(fm.model.as_deref(), Some("opus"));
707        assert_eq!(fm.session.as_deref(), Some("abc"));
708    }
709
710    #[test]
711    fn merge_fields_multiple_fields() {
712        let content = "---\nagent_doc_session: abc\n---\nBody\n";
713        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
714        let (fm, _) = parse(&result).unwrap();
715        assert_eq!(fm.model.as_deref(), Some("opus"));
716        assert_eq!(fm.agent.as_deref(), Some("claude"));
717        assert_eq!(fm.branch.as_deref(), Some("main"));
718    }
719
720    #[test]
721    fn merge_fields_format_enum() {
722        let content = "---\nagent_doc_session: abc\n---\nBody\n";
723        let result = merge_fields(content, "agent_doc_format: append").unwrap();
724        let (fm, _) = parse(&result).unwrap();
725        assert_eq!(fm.format, Some(AgentDocFormat::Append));
726    }
727
728    #[test]
729    fn merge_fields_write_enum() {
730        let content = "---\nagent_doc_session: abc\n---\nBody\n";
731        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
732        let (fm, _) = parse(&result).unwrap();
733        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
734    }
735
736    #[test]
737    fn merge_fields_ignores_unknown() {
738        let content = "---\nagent_doc_session: abc\n---\nBody\n";
739        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
740        let (fm, _) = parse(&result).unwrap();
741        assert_eq!(fm.model.as_deref(), Some("opus"));
742    }
743
744    #[test]
745    fn merge_fields_preserves_body() {
746        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
747        let result = merge_fields(content, "model: opus").unwrap();
748        assert!(result.contains("# Title"));
749        assert!(result.contains("Some **markdown** content."));
750    }
751
752    #[test]
753    fn set_format_and_write_clears_deprecated() {
754        let content = "---\nagent_doc_mode: append\n---\nBody\n";
755        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
756        let (fm, _) = parse(&result).unwrap();
757        assert!(fm.mode.is_none());
758        assert_eq!(fm.format, Some(AgentDocFormat::Template));
759        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
760    }
761}