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    /// **Deprecated.** Tmux session name for pane affinity (e.g., "claude").
134    /// Session is now determined at runtime by `--window` argument (sync) or
135    /// `current_tmux_session()` (route/start). Still read for backward compatibility
136    /// and auto-repaired by sync when it differs from the context session.
137    /// Will be removed in a future version.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub tmux_session: Option<String>,
140    /// **Deprecated.** Use `agent_doc_format` + `agent_doc_write` instead.
141    /// Kept for backward compatibility. Values: "append", "template", "stream".
142    /// Serialized as `agent_doc_mode` in YAML; reads legacy `response_mode` and shorthand `mode` via aliases.
143    #[serde(
144        default,
145        skip_serializing_if = "Option::is_none",
146        rename = "agent_doc_mode",
147        alias = "mode",
148        alias = "response_mode"
149    )]
150    pub mode: Option<String>,
151    /// Document format: controls document structure (append | template).
152    #[serde(
153        default,
154        skip_serializing_if = "Option::is_none",
155        rename = "agent_doc_format"
156    )]
157    pub format: Option<AgentDocFormat>,
158    /// Write strategy: controls merge behavior (merge | crdt).
159    #[serde(
160        default,
161        skip_serializing_if = "Option::is_none",
162        rename = "agent_doc_write"
163    )]
164    pub write_mode: Option<AgentDocWrite>,
165    /// Stream mode configuration (used when write strategy is CRDT).
166    #[serde(
167        default,
168        skip_serializing_if = "Option::is_none",
169        rename = "agent_doc_stream"
170    )]
171    pub stream_config: Option<StreamConfig>,
172    /// Additional CLI arguments to pass to the `claude` process.
173    /// Space-separated string (e.g., "--dangerously-skip-permissions").
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub claude_args: Option<String>,
176}
177
178impl Frontmatter {
179    /// Resolve the canonical (format, write) pair from all three fields.
180    ///
181    /// Priority:
182    /// 1. Explicit `agent_doc_format` / `agent_doc_write` fields (highest)
183    /// 2. Deprecated `agent_doc_mode` field (auto-migrated)
184    /// 3. Defaults: format=template, write=crdt
185    pub fn resolve_mode(&self) -> ResolvedMode {
186        // Start with defaults
187        let mut format = AgentDocFormat::Template;
188        let mut write = AgentDocWrite::Crdt;
189
190        // Apply deprecated mode if present (lowest priority)
191        if let Some(ref mode_str) = self.mode {
192            match mode_str.as_str() {
193                "append" => {
194                    format = AgentDocFormat::Append;
195                    // write stays crdt (user preference: always crdt)
196                }
197                "template" => {
198                    format = AgentDocFormat::Template;
199                    // write stays crdt
200                }
201                "stream" => {
202                    format = AgentDocFormat::Template;
203                    write = AgentDocWrite::Crdt;
204                }
205                _ => {} // unknown mode, use defaults
206            }
207        }
208
209        // Override with explicit new fields (highest priority)
210        if let Some(f) = self.format {
211            format = f;
212        }
213        if let Some(w) = self.write_mode {
214            write = w;
215        }
216
217        ResolvedMode { format, write }
218    }
219}
220
221/// Parse YAML frontmatter from a document. Returns (frontmatter, body).
222/// If no frontmatter block is present, returns defaults and the full content as body.
223pub fn parse(content: &str) -> Result<(Frontmatter, &str)> {
224    if !content.starts_with("---\n") {
225        return Ok((Frontmatter::default(), content));
226    }
227    let rest = &content[4..]; // skip opening ---\n
228    let end = rest
229        .find("\n---\n")
230        .or_else(|| rest.find("\n---"))
231        .ok_or_else(|| anyhow::anyhow!("Unterminated frontmatter block"))?;
232    let yaml = &rest[..end];
233    let fm: Frontmatter = serde_yaml::from_str(yaml)?;
234    let body_start = 4 + end + 4; // opening --- + yaml + closing ---\n
235    let body = if body_start <= content.len() {
236        &content[body_start..]
237    } else {
238        ""
239    };
240    Ok((fm, body))
241}
242
243/// Write frontmatter back into a document, preserving the body.
244pub fn write(fm: &Frontmatter, body: &str) -> Result<String> {
245    let yaml = serde_yaml::to_string(fm)?;
246    Ok(format!("---\n{}---\n{}", yaml, body))
247}
248
249/// Update the session ID in a document string. Creates frontmatter if missing.
250pub fn set_session_id(content: &str, session_id: &str) -> Result<String> {
251    let (mut fm, body) = parse(content)?;
252    fm.session = Some(session_id.to_string());
253    write(&fm, body)
254}
255
256/// Update the resume (agent conversation) ID in a document string.
257pub fn set_resume_id(content: &str, resume_id: &str) -> Result<String> {
258    let (mut fm, body) = parse(content)?;
259    fm.resume = Some(resume_id.to_string());
260    write(&fm, body)
261}
262
263/// Set both agent_doc_format and agent_doc_write, clearing deprecated agent_doc_mode.
264pub fn set_format_and_write(
265    content: &str,
266    format: AgentDocFormat,
267    write_mode: AgentDocWrite,
268) -> Result<String> {
269    let (mut fm, body) = parse(content)?;
270    fm.format = Some(format);
271    fm.write_mode = Some(write_mode);
272    fm.mode = None;
273    write(&fm, body)
274}
275
276/// Merge YAML key/value pairs into a document's frontmatter.
277///
278/// Takes a YAML string of fields to merge (additive — never removes keys).
279/// Only known frontmatter fields are applied; unknown keys are ignored.
280/// Returns the updated document content.
281pub fn merge_fields(content: &str, yaml_fields: &str) -> Result<String> {
282    let (mut fm, body) = parse(content)?;
283    let patch: serde_yaml::Value = serde_yaml::from_str(yaml_fields)
284        .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
285    let mapping = patch.as_mapping().unwrap_or(&serde_yaml::Mapping::new()).clone();
286
287    for (key, value) in &mapping {
288        let key_str = key.as_str().unwrap_or("");
289        let val_str = || value.as_str().map(|s| s.to_string());
290        match key_str {
291            "agent_doc_session" | "session" => fm.session = val_str(),
292            "resume" => fm.resume = val_str(),
293            "agent" => fm.agent = val_str(),
294            "model" => fm.model = val_str(),
295            "branch" => fm.branch = val_str(),
296            "tmux_session" => fm.tmux_session = val_str(),
297            "agent_doc_mode" | "mode" | "response_mode" => fm.mode = val_str(),
298            "agent_doc_format" => {
299                if let Some(s) = value.as_str()
300                    && let Ok(f) = serde_yaml::from_str::<AgentDocFormat>(&format!("\"{}\"", s))
301                {
302                    fm.format = Some(f);
303                }
304            }
305            "agent_doc_write" => {
306                if let Some(s) = value.as_str()
307                    && let Ok(w) = serde_yaml::from_str::<AgentDocWrite>(&format!("\"{}\"", s))
308                {
309                    fm.write_mode = Some(w);
310                }
311            }
312            "claude_args" => fm.claude_args = val_str(),
313            _ => {
314                eprintln!("[frontmatter] ignoring unknown patch field: {}", key_str);
315            }
316        }
317    }
318
319    write(&fm, body)
320}
321
322/// Update the tmux_session name in a document string.
323///
324/// **Deprecated.** `tmux_session` in frontmatter is deprecated — session is now
325/// determined at runtime. This function is retained for backward compatibility
326/// (claim and sync still write it so older binaries can read it).
327pub fn set_tmux_session(content: &str, session_name: &str) -> Result<String> {
328    let (mut fm, body) = parse(content)?;
329    fm.tmux_session = Some(session_name.to_string());
330    write(&fm, body)
331}
332
333/// Ensure the document has a session ID. If no frontmatter exists, creates one
334/// with a new UUID v4. If frontmatter exists but session is None/null, generates
335/// a UUID and sets it. If session already exists, returns as-is.
336/// Returns (updated_content, session_id).
337pub fn ensure_session(content: &str) -> Result<(String, String)> {
338    let (fm, _body) = parse(content)?;
339    if let Some(ref session_id) = fm.session {
340        // Session already set — return content unchanged
341        return Ok((content.to_string(), session_id.clone()));
342    }
343    let session_id = Uuid::new_v4().to_string();
344    let updated = set_session_id(content, &session_id)?;
345    Ok((updated, session_id))
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn parse_no_frontmatter() {
354        let content = "# Hello\n\nBody text.\n";
355        let (fm, body) = parse(content).unwrap();
356        assert!(fm.session.is_none());
357        assert!(fm.agent.is_none());
358        assert!(fm.model.is_none());
359        assert!(fm.branch.is_none());
360        assert_eq!(body, content);
361    }
362
363    #[test]
364    fn parse_all_fields() {
365        let content = "---\nsession: abc-123\nagent: claude\nmodel: opus\nbranch: main\n---\nBody\n";
366        let (fm, body) = parse(content).unwrap();
367        assert_eq!(fm.session.as_deref(), Some("abc-123"));
368        assert_eq!(fm.agent.as_deref(), Some("claude"));
369        assert_eq!(fm.model.as_deref(), Some("opus"));
370        assert_eq!(fm.branch.as_deref(), Some("main"));
371        assert!(body.contains("Body"));
372    }
373
374    #[test]
375    fn parse_partial_fields() {
376        let content = "---\nsession: xyz\n---\n# Doc\n";
377        let (fm, body) = parse(content).unwrap();
378        assert_eq!(fm.session.as_deref(), Some("xyz"));
379        assert!(fm.agent.is_none());
380        assert!(body.contains("# Doc"));
381    }
382
383    #[test]
384    fn parse_null_fields() {
385        let content = "---\nsession: null\nagent: null\nmodel: null\nbranch: null\n---\nBody\n";
386        let (fm, body) = parse(content).unwrap();
387        assert!(fm.session.is_none());
388        assert!(fm.agent.is_none());
389        assert!(fm.model.is_none());
390        assert!(fm.branch.is_none());
391        assert!(body.contains("Body"));
392    }
393
394    #[test]
395    fn parse_unterminated_frontmatter() {
396        let content = "---\nsession: abc\nno closing block";
397        let err = parse(content).unwrap_err();
398        assert!(err.to_string().contains("Unterminated frontmatter"));
399    }
400
401    #[test]
402    fn parse_closing_at_eof() {
403        let content = "---\nsession: abc\n---";
404        let (fm, body) = parse(content).unwrap();
405        assert_eq!(fm.session.as_deref(), Some("abc"));
406        assert_eq!(body, "");
407    }
408
409    #[test]
410    fn parse_empty_body() {
411        let content = "---\nsession: abc\n---\n";
412        let (fm, _body) = parse(content).unwrap();
413        assert_eq!(fm.session.as_deref(), Some("abc"));
414    }
415
416    #[test]
417    fn write_roundtrip() {
418        // Start from write output to ensure consistent formatting
419        let fm = Frontmatter {
420            session: Some("test-id".to_string()),
421            resume: Some("resume-id".to_string()),
422            agent: Some("claude".to_string()),
423            model: Some("opus".to_string()),
424            branch: Some("dev".to_string()),
425            tmux_session: None,
426            mode: None,
427            format: None,
428            write_mode: None,
429            stream_config: None,
430            claude_args: None,
431        };
432        let body = "# Hello\n\nBody text.\n";
433        let written = write(&fm, body).unwrap();
434        let (fm2, body2) = parse(&written).unwrap();
435        assert_eq!(fm2.session, fm.session);
436        assert_eq!(fm2.agent, fm.agent);
437        assert_eq!(fm2.model, fm.model);
438        assert_eq!(fm2.branch, fm.branch);
439        // Roundtrip preserves body (may have leading newline from parse)
440        assert!(body2.contains("# Hello"));
441        assert!(body2.contains("Body text."));
442    }
443
444    #[test]
445    fn write_default_frontmatter() {
446        let fm = Frontmatter::default();
447        let result = write(&fm, "body\n").unwrap();
448        assert!(result.starts_with("---\n"));
449        assert!(result.ends_with("---\nbody\n"));
450    }
451
452    #[test]
453    fn write_preserves_body_content() {
454        let fm = Frontmatter::default();
455        let body = "# Title\n\nSome **markdown** with `code`.\n";
456        let result = write(&fm, body).unwrap();
457        assert!(result.contains("# Title"));
458        assert!(result.contains("Some **markdown** with `code`."));
459    }
460
461    #[test]
462    fn set_session_id_creates_frontmatter() {
463        let content = "# No frontmatter\n\nJust body.\n";
464        let result = set_session_id(content, "new-session").unwrap();
465        let (fm, body) = parse(&result).unwrap();
466        assert_eq!(fm.session.as_deref(), Some("new-session"));
467        assert!(body.contains("# No frontmatter"));
468    }
469
470    #[test]
471    fn set_session_id_updates_existing() {
472        let content = "---\nsession: old-id\nagent: claude\n---\nBody\n";
473        let result = set_session_id(content, "new-id").unwrap();
474        let (fm, body) = parse(&result).unwrap();
475        assert_eq!(fm.session.as_deref(), Some("new-id"));
476        assert_eq!(fm.agent.as_deref(), Some("claude"));
477        assert!(body.contains("Body"));
478    }
479
480    #[test]
481    fn set_session_id_preserves_other_fields() {
482        let content = "---\nsession: old\nagent: claude\nmodel: opus\nbranch: dev\n---\nBody\n";
483        let result = set_session_id(content, "new").unwrap();
484        let (fm, _) = parse(&result).unwrap();
485        assert_eq!(fm.session.as_deref(), Some("new"));
486        assert_eq!(fm.agent.as_deref(), Some("claude"));
487        assert_eq!(fm.model.as_deref(), Some("opus"));
488        assert_eq!(fm.branch.as_deref(), Some("dev"));
489    }
490
491    #[test]
492    fn ensure_session_no_frontmatter() {
493        let content = "# Hello\n\nBody.\n";
494        let (updated, sid) = ensure_session(content).unwrap();
495        // Should have generated a UUID
496        assert_eq!(sid.len(), 36); // UUID v4 string length
497        let (fm, body) = parse(&updated).unwrap();
498        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
499        assert!(body.contains("# Hello"));
500    }
501
502    #[test]
503    fn ensure_session_null_session() {
504        let content = "---\nsession:\nagent: claude\n---\nBody\n";
505        let (updated, sid) = ensure_session(content).unwrap();
506        assert_eq!(sid.len(), 36);
507        let (fm, body) = parse(&updated).unwrap();
508        assert_eq!(fm.session.as_deref(), Some(sid.as_str()));
509        assert_eq!(fm.agent.as_deref(), Some("claude"));
510        assert!(body.contains("Body"));
511    }
512
513    #[test]
514    fn ensure_session_existing_session() {
515        let content = "---\nagent_doc_session: existing-id\nagent: claude\n---\nBody\n";
516        let (updated, sid) = ensure_session(content).unwrap();
517        assert_eq!(sid, "existing-id");
518        // Content should be unchanged
519        assert_eq!(updated, content);
520    }
521
522    #[test]
523    fn parse_legacy_session_field() {
524        // Old `session:` field should still parse via serde alias
525        let content = "---\nsession: legacy-id\nagent: claude\n---\nBody\n";
526        let (fm, body) = parse(content).unwrap();
527        assert_eq!(fm.session.as_deref(), Some("legacy-id"));
528        assert_eq!(fm.agent.as_deref(), Some("claude"));
529        assert!(body.contains("Body"));
530    }
531
532    #[test]
533    fn parse_agent_doc_mode_canonical() {
534        let content = "---\nagent_doc_mode: 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_mode_shorthand_alias() {
541        let content = "---\nmode: 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 parse_response_mode_legacy_alias() {
548        let content = "---\nresponse_mode: template\n---\nBody\n";
549        let (fm, _) = parse(content).unwrap();
550        assert_eq!(fm.mode.as_deref(), Some("template"));
551    }
552
553    #[test]
554    fn write_uses_agent_doc_mode_field() {
555        #[allow(deprecated)]
556        let fm = Frontmatter {
557            mode: Some("template".to_string()),
558            ..Default::default()
559        };
560        let result = write(&fm, "body\n").unwrap();
561        assert!(result.contains("agent_doc_mode:"));
562        assert!(!result.contains("response_mode:"));
563        assert!(!result.contains("\nmode:"));
564    }
565
566    #[test]
567    fn write_uses_new_field_name() {
568        let fm = Frontmatter {
569            session: Some("test-id".to_string()),
570            ..Default::default()
571        };
572        let result = write(&fm, "body\n").unwrap();
573        assert!(result.contains("agent_doc_session:"));
574        assert!(!result.contains("\nsession:"));
575    }
576
577    // --- resolve_mode tests ---
578
579    #[test]
580    fn resolve_mode_defaults() {
581        let fm = Frontmatter::default();
582        let resolved = fm.resolve_mode();
583        assert_eq!(resolved.format, AgentDocFormat::Template);
584        assert_eq!(resolved.write, AgentDocWrite::Crdt);
585    }
586
587    #[test]
588    fn resolve_mode_from_deprecated_append() {
589        let content = "---\nagent_doc_mode: append\n---\nBody\n";
590        let (fm, _) = parse(content).unwrap();
591        let resolved = fm.resolve_mode();
592        assert_eq!(resolved.format, AgentDocFormat::Append);
593        assert_eq!(resolved.write, AgentDocWrite::Crdt);
594    }
595
596    #[test]
597    fn resolve_mode_from_deprecated_template() {
598        let content = "---\nagent_doc_mode: template\n---\nBody\n";
599        let (fm, _) = parse(content).unwrap();
600        let resolved = fm.resolve_mode();
601        assert_eq!(resolved.format, AgentDocFormat::Template);
602        assert_eq!(resolved.write, AgentDocWrite::Crdt);
603    }
604
605    #[test]
606    fn resolve_mode_from_deprecated_stream() {
607        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
608        let (fm, _) = parse(content).unwrap();
609        let resolved = fm.resolve_mode();
610        assert_eq!(resolved.format, AgentDocFormat::Template);
611        assert_eq!(resolved.write, AgentDocWrite::Crdt);
612    }
613
614    #[test]
615    fn resolve_mode_new_fields_override_deprecated() {
616        let content = "---\nagent_doc_mode: append\nagent_doc_format: template\nagent_doc_write: merge\n---\nBody\n";
617        let (fm, _) = parse(content).unwrap();
618        let resolved = fm.resolve_mode();
619        assert_eq!(resolved.format, AgentDocFormat::Template);
620        assert_eq!(resolved.write, AgentDocWrite::Merge);
621    }
622
623    #[test]
624    fn resolve_mode_explicit_new_fields_only() {
625        let content = "---\nagent_doc_format: append\nagent_doc_write: crdt\n---\nBody\n";
626        let (fm, _) = parse(content).unwrap();
627        let resolved = fm.resolve_mode();
628        assert_eq!(resolved.format, AgentDocFormat::Append);
629        assert_eq!(resolved.write, AgentDocWrite::Crdt);
630    }
631
632    #[test]
633    fn resolve_mode_partial_new_field_format_only() {
634        let content = "---\nagent_doc_format: append\n---\nBody\n";
635        let (fm, _) = parse(content).unwrap();
636        let resolved = fm.resolve_mode();
637        assert_eq!(resolved.format, AgentDocFormat::Append);
638        assert_eq!(resolved.write, AgentDocWrite::Crdt); // default
639    }
640
641    #[test]
642    fn resolve_mode_partial_new_field_write_only() {
643        let content = "---\nagent_doc_write: merge\n---\nBody\n";
644        let (fm, _) = parse(content).unwrap();
645        let resolved = fm.resolve_mode();
646        assert_eq!(resolved.format, AgentDocFormat::Template); // default
647        assert_eq!(resolved.write, AgentDocWrite::Merge);
648    }
649
650    #[test]
651    fn resolve_mode_helper_methods() {
652        let fm = Frontmatter::default();
653        let resolved = fm.resolve_mode();
654        assert!(resolved.is_template());
655        assert!(!resolved.is_append());
656        assert!(resolved.is_crdt());
657    }
658
659    #[test]
660    fn parse_new_format_field() {
661        let content = "---\nagent_doc_format: template\n---\nBody\n";
662        let (fm, _) = parse(content).unwrap();
663        assert_eq!(fm.format, Some(AgentDocFormat::Template));
664    }
665
666    #[test]
667    fn parse_new_write_field() {
668        let content = "---\nagent_doc_write: crdt\n---\nBody\n";
669        let (fm, _) = parse(content).unwrap();
670        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
671    }
672
673    #[test]
674    fn write_uses_new_format_write_fields() {
675        let fm = Frontmatter {
676            format: Some(AgentDocFormat::Template),
677            write_mode: Some(AgentDocWrite::Crdt),
678            ..Default::default()
679        };
680        let result = write(&fm, "body\n").unwrap();
681        assert!(result.contains("agent_doc_format:"));
682        assert!(result.contains("agent_doc_write:"));
683        assert!(!result.contains("agent_doc_mode:"));
684    }
685
686    #[test]
687    fn set_format_and_write_clears_deprecated_mode() {
688        let content = "---\nagent_doc_mode: stream\n---\nBody\n";
689        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
690        let (fm, _) = parse(&result).unwrap();
691        assert!(fm.mode.is_none());
692        assert_eq!(fm.format, Some(AgentDocFormat::Template));
693        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
694    }
695
696    // --- merge_fields tests ---
697
698    #[test]
699    fn merge_fields_adds_new_field() {
700        let content = "---\nagent_doc_session: abc\n---\nBody\n";
701        let result = merge_fields(content, "model: opus").unwrap();
702        let (fm, body) = parse(&result).unwrap();
703        assert_eq!(fm.session.as_deref(), Some("abc"));
704        assert_eq!(fm.model.as_deref(), Some("opus"));
705        assert!(body.contains("Body"));
706    }
707
708    #[test]
709    fn merge_fields_updates_existing_field() {
710        let content = "---\nagent_doc_session: abc\nmodel: sonnet\n---\nBody\n";
711        let result = merge_fields(content, "model: opus").unwrap();
712        let (fm, _) = parse(&result).unwrap();
713        assert_eq!(fm.model.as_deref(), Some("opus"));
714        assert_eq!(fm.session.as_deref(), Some("abc"));
715    }
716
717    #[test]
718    fn merge_fields_multiple_fields() {
719        let content = "---\nagent_doc_session: abc\n---\nBody\n";
720        let result = merge_fields(content, "model: opus\nagent: claude\nbranch: main").unwrap();
721        let (fm, _) = parse(&result).unwrap();
722        assert_eq!(fm.model.as_deref(), Some("opus"));
723        assert_eq!(fm.agent.as_deref(), Some("claude"));
724        assert_eq!(fm.branch.as_deref(), Some("main"));
725    }
726
727    #[test]
728    fn merge_fields_format_enum() {
729        let content = "---\nagent_doc_session: abc\n---\nBody\n";
730        let result = merge_fields(content, "agent_doc_format: append").unwrap();
731        let (fm, _) = parse(&result).unwrap();
732        assert_eq!(fm.format, Some(AgentDocFormat::Append));
733    }
734
735    #[test]
736    fn merge_fields_write_enum() {
737        let content = "---\nagent_doc_session: abc\n---\nBody\n";
738        let result = merge_fields(content, "agent_doc_write: merge").unwrap();
739        let (fm, _) = parse(&result).unwrap();
740        assert_eq!(fm.write_mode, Some(AgentDocWrite::Merge));
741    }
742
743    #[test]
744    fn merge_fields_ignores_unknown() {
745        let content = "---\nagent_doc_session: abc\n---\nBody\n";
746        let result = merge_fields(content, "unknown_field: value\nmodel: opus").unwrap();
747        let (fm, _) = parse(&result).unwrap();
748        assert_eq!(fm.model.as_deref(), Some("opus"));
749    }
750
751    #[test]
752    fn merge_fields_preserves_body() {
753        let content = "---\nagent_doc_session: abc\n---\n# Title\n\nSome **markdown** content.\n";
754        let result = merge_fields(content, "model: opus").unwrap();
755        assert!(result.contains("# Title"));
756        assert!(result.contains("Some **markdown** content."));
757    }
758
759    #[test]
760    fn set_format_and_write_clears_deprecated() {
761        let content = "---\nagent_doc_mode: append\n---\nBody\n";
762        let result = set_format_and_write(content, AgentDocFormat::Template, AgentDocWrite::Crdt).unwrap();
763        let (fm, _) = parse(&result).unwrap();
764        assert!(fm.mode.is_none());
765        assert_eq!(fm.format, Some(AgentDocFormat::Template));
766        assert_eq!(fm.write_mode, Some(AgentDocWrite::Crdt));
767    }
768}