Skip to main content

agent_doc/
template.rs

1//! # Module: template
2//!
3//! Template-mode support for in-place response documents. Parses structured patch
4//! blocks from agent responses and applies them to named component slots in the
5//! document, with boundary marker lifecycle management for CRDT-safe stream writes.
6//!
7//! ## Spec
8//! - `parse_patches`: Scans agent response text for `<!-- patch:name -->...<!-- /patch:name -->`
9//!   blocks and returns a list of `PatchBlock` values plus any unmatched text (content outside
10//!   patch blocks). Markers inside fenced code blocks (``` or ~~~) and inline code spans are
11//!   ignored so examples in responses are never mis-parsed as real patches.
12//! - `apply_patches`: Delegates to `apply_patches_with_overrides` with an empty override map.
13//!   Applies parsed patches to matching `<!-- agent:name -->` components in the document.
14//! - `apply_patches_with_overrides`: Core patch application pipeline:
15//!   1. Pre-patch: strips all existing boundary markers, inserts a fresh boundary at the end
16//!      of the `exchange` component (keyed to the file stem).
17//!   2. Applies each patch using mode resolution: stream overrides > inline attr (`patch=` or
18//!      `mode=`) > `.agent-doc/components.toml` > built-in defaults (`exchange`/`findings`
19//!      default to `append`, all others to `replace`).
20//!   3. For `append` mode, uses boundary-aware insertion when a boundary marker exists.
21//!   4. Patches targeting missing component names are routed as overflow to `exchange`/`output`.
22//!   5. Unmatched text and overflow are merged and appended to `exchange`/`output` (or an
23//!      auto-created `exchange` component if none exists).
24//!   6. Post-patch: if the boundary was consumed, re-inserts a fresh one at the end of exchange.
25//! - `reposition_boundary_to_end`: Removes all boundaries and inserts a new one at the end of
26//!   `exchange`. Used by the IPC write path to keep boundary position current.
27//! - `reposition_boundary_to_end_with_summary`: Same, with optional human-readable suffix on
28//!   the boundary ID (e.g. `a0cfeb34:agent-doc`).
29//! - `template_info`: Reads a document file, resolves its template mode flag, and returns a
30//!   serializable `TemplateInfo` with per-component name, resolved mode, content, and line
31//!   number. Used by editor plugins for rendering.
32//! - `is_template_mode` (test-only): Legacy helper to detect `mode = "template"` string.
33//!
34//! ## Agentic Contracts
35//! - `parse_patches` is pure and infallible for valid UTF-8; it returns `Ok` even for empty
36//!   or patch-free responses.
37//! - Patch markers inside fenced code blocks are never extracted as real patches. Agents may
38//!   include example markers in code blocks without triggering unintended writes.
39//! - Component patches are applied in reverse document order so earlier byte offsets remain
40//!   valid throughout the operation.
41//! - A boundary marker always exists at the end of `exchange` after `apply_patches_with_overrides`
42//!   returns. Callers that perform incremental (CRDT/stream) writes may rely on this invariant.
43//! - Missing-component patches never cause errors; content is silently routed to `exchange`/
44//!   `output` with a diagnostic written to stderr.
45//! - Mode precedence is deterministic: stream override > inline attr (`patch=` > `mode=`) >
46//!   `components.toml` (`patch` key > `mode` key) > built-in default. Callers can rely on this
47//!   ordering when constructing overrides for stream mode.
48//! - `template_info` reads the document from disk; callers must ensure the file is flushed
49//!   before calling (especially in the IPC write path).
50//!
51//! ## Evals
52//! - `parse_single_patch`: single patch block → one `PatchBlock`, empty unmatched
53//! - `parse_multiple_patches`: two sequential patch blocks → two `PatchBlock`s in order, empty unmatched
54//! - `parse_with_unmatched_content`: text before and after patch block → unmatched contains both text segments
55//! - `parse_empty_response`: empty string → zero patches, empty unmatched
56//! - `parse_no_patches`: plain text with no markers → zero patches, full text in unmatched
57//! - `parse_patches_ignores_markers_in_fenced_code_block`: agent:component markers inside ``` are preserved as content
58//! - `parse_patches_ignores_patch_markers_in_fenced_code_block`: nested patch markers inside ``` are not parsed as patches
59//! - `parse_patches_ignores_markers_in_tilde_fence`: patch markers inside ~~~ are ignored
60//! - `parse_patches_ignores_closing_marker_in_code_block`: closing marker inside code block is skipped; real close is found
61//! - `parse_patches_normal_markers_still_work`: sanity — two back-to-back patches parse correctly
62//! - `apply_patches_replace`: patch to non-exchange component replaces existing content
63//! - `apply_patches_unmatched_creates_exchange`: unmatched text auto-creates `<!-- agent:exchange -->` when absent
64//! - `apply_patches_unmatched_appends_to_existing_exchange`: unmatched text appends to existing exchange; no duplicate component
65//! - `apply_patches_missing_component_routes_to_exchange`: patch targeting unknown component name appears in exchange
66//! - `apply_patches_missing_component_creates_exchange`: missing component + no exchange → auto-creates exchange with overflow
67//! - `inline_attr_mode_overrides_config`: `mode=replace` on tag wins over `components.toml` append config
68//! - `inline_attr_mode_overrides_default`: `mode=replace` on exchange wins over built-in append default
69//! - `no_inline_attr_falls_back_to_config`: no inline attr → `components.toml` append config applies
70//! - `no_inline_attr_no_config_falls_back_to_default`: no attr, no config → exchange defaults to append
71//! - `inline_patch_attr_overrides_config`: `patch=replace` on tag wins over `components.toml` append config
72//! - `template_info_works`: template-mode doc → `TemplateInfo.template_mode = true`, component list populated
73//! - `template_info_legacy_mode_works`: `response_mode: template` frontmatter key recognized
74//! - `template_info_append_mode`: non-template doc → `template_mode = false`, empty component list
75//! - `is_template_mode_detection`: `Some("template")` → true; other strings and `None` → false
76//! - (aspirational) `apply_patches_boundary_invariant`: after any apply_patches call with an exchange component, a boundary marker exists at end of exchange
77//! - (aspirational) `reposition_boundary_removes_stale`: multiple stale boundaries are reduced to exactly one at end of exchange
78
79use anyhow::{Context, Result};
80use serde::Serialize;
81use std::path::Path;
82
83use crate::component::{self, find_comment_end, Component};
84
85/// A parsed patch directive from an agent response.
86#[derive(Debug, Clone)]
87pub struct PatchBlock {
88    pub name: String,
89    pub content: String,
90}
91
92/// Template info output for plugins.
93#[derive(Debug, Serialize)]
94pub struct TemplateInfo {
95    pub template_mode: bool,
96    pub components: Vec<ComponentInfo>,
97}
98
99/// Per-component info for plugin rendering.
100#[derive(Debug, Serialize)]
101pub struct ComponentInfo {
102    pub name: String,
103    pub mode: String,
104    pub content: String,
105    pub line: usize,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub max_entries: Option<usize>,
108}
109
110/// Check if a document is in template mode (deprecated — use `fm.resolve_mode().is_template()`).
111#[cfg(test)]
112pub fn is_template_mode(mode: Option<&str>) -> bool {
113    matches!(mode, Some("template"))
114}
115
116/// Parse `<!-- patch:name -->...<!-- /patch:name -->` blocks from an agent response.
117///
118/// Content outside patch blocks is collected as "unmatched" and returned separately.
119/// Markers inside fenced code blocks (``` or ~~~) and inline code spans are ignored.
120pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
121    let bytes = response.as_bytes();
122    let len = bytes.len();
123    let code_ranges = component::find_code_ranges(response);
124    let mut patches = Vec::new();
125    let mut unmatched = String::new();
126    let mut pos = 0;
127    let mut last_end = 0;
128
129    while pos + 4 <= len {
130        if &bytes[pos..pos + 4] != b"<!--" {
131            pos += 1;
132            continue;
133        }
134
135        // Skip markers inside code regions
136        if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
137            pos += 4;
138            continue;
139        }
140
141        let marker_start = pos;
142
143        // Find closing -->
144        let close = match find_comment_end(bytes, pos + 4) {
145            Some(c) => c,
146            None => {
147                pos += 4;
148                continue;
149            }
150        };
151
152        let inner = &response[marker_start + 4..close - 3];
153        let trimmed = inner.trim();
154
155        if let Some(name) = trimmed.strip_prefix("patch:") {
156            let name = name.trim();
157            if name.is_empty() || name.starts_with('/') {
158                pos = close;
159                continue;
160            }
161
162            // Consume trailing newline after opening marker
163            let mut content_start = close;
164            if content_start < len && bytes[content_start] == b'\n' {
165                content_start += 1;
166            }
167
168            // Collect unmatched text before this patch block
169            let before = &response[last_end..marker_start];
170            let trimmed_before = before.trim();
171            if !trimmed_before.is_empty() {
172                if !unmatched.is_empty() {
173                    unmatched.push('\n');
174                }
175                unmatched.push_str(trimmed_before);
176            }
177
178            // Find the matching close: <!-- /patch:name --> (skipping code blocks)
179            let close_marker = format!("<!-- /patch:{} -->", name);
180            if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
181                let content = &response[content_start..close_pos];
182                patches.push(PatchBlock {
183                    name: name.to_string(),
184                    content: content.to_string(),
185                });
186
187                let mut end = close_pos + close_marker.len();
188                if end < len && bytes[end] == b'\n' {
189                    end += 1;
190                }
191                last_end = end;
192                pos = end;
193                continue;
194            }
195        }
196
197        pos = close;
198    }
199
200    // Collect any trailing unmatched text
201    if last_end < len {
202        let trailing = response[last_end..].trim();
203        if !trailing.is_empty() {
204            if !unmatched.is_empty() {
205                unmatched.push('\n');
206            }
207            unmatched.push_str(trailing);
208        }
209    }
210
211    Ok((patches, unmatched))
212}
213
214/// Apply patch blocks to a document's components.
215///
216/// For each patch block, finds the matching `<!-- agent:name -->` component
217/// and replaces its content. Uses patch.rs mode logic (replace/append/prepend)
218/// based on `.agent-doc/components.toml` config.
219///
220/// Returns the modified document. Unmatched content (outside patch blocks)
221/// is appended to `<!-- agent:output -->` if it exists, or creates one at the end.
222pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
223    apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
224}
225
226/// Apply patches with per-component mode overrides (e.g., stream mode forces "replace"
227/// for cumulative buffers even on append-mode components like exchange).
228pub fn apply_patches_with_overrides(
229    doc: &str,
230    patches: &[PatchBlock],
231    unmatched: &str,
232    file: &Path,
233    mode_overrides: &std::collections::HashMap<String, String>,
234) -> Result<String> {
235    // Pre-patch: ensure a fresh boundary exists in the exchange component.
236    // Remove any stale boundaries from previous cycles, then insert a new one
237    // at the end of the exchange. This is deterministic — belongs in the binary,
238    // not the SKILL workflow.
239    let summary = file.file_stem().and_then(|s| s.to_str());
240    let mut result = remove_all_boundaries(doc);
241    if let Ok(components) = component::parse(&result)
242        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
243    {
244        let id = crate::new_boundary_id_with_summary(summary);
245        let marker = crate::format_boundary_marker(&id);
246        let content = exchange.content(&result);
247        let new_content = format!("{}\n{}\n", content.trim_end(), marker);
248        result = exchange.replace_content(&result, &new_content);
249        eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
250    }
251
252    // Apply patches in reverse order (by position) to preserve byte offsets
253    let components = component::parse(&result)
254        .context("failed to parse components")?;
255
256    // Load component configs
257    let configs = load_component_configs(file);
258
259    // Build a list of (component_index, patch) pairs, sorted by component position descending.
260    // Patches targeting missing components are collected as overflow and routed to
261    // exchange/output (same as unmatched content) — this avoids silent failures when
262    // the agent uses a wrong component name.
263    let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
264    let mut overflow = String::new();
265    for patch in patches {
266        if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
267            ops.push((idx, patch));
268        } else {
269            let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
270            eprintln!(
271                "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
272                patch.name,
273                available.join(", ")
274            );
275            if !overflow.is_empty() {
276                overflow.push('\n');
277            }
278            overflow.push_str(&patch.content);
279        }
280    }
281
282    // Sort by position descending so replacements don't shift earlier offsets
283    ops.sort_by(|a, b| b.0.cmp(&a.0));
284
285    for (idx, patch) in &ops {
286        let comp = &components[*idx];
287        // Mode precedence: stream overrides > inline attr > components.toml > built-in default
288        let mode = mode_overrides.get(&patch.name)
289            .map(|s| s.as_str())
290            .or_else(|| comp.patch_mode())
291            .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
292            .unwrap_or_else(|| default_mode(&patch.name));
293        // For append mode, use boundary-aware insertion when a marker exists
294        if mode == "append"
295            && let Some(bid) = find_boundary_in_component(&result, comp)
296        {
297            result = comp.append_with_boundary(&result, &patch.content, &bid);
298            continue;
299        }
300        let new_content = apply_mode(mode, comp.content(&result), &patch.content);
301        result = comp.replace_content(&result, &new_content);
302    }
303
304    // Merge overflow (from missing-component patches) with unmatched content
305    let mut all_unmatched = String::new();
306    if !overflow.is_empty() {
307        all_unmatched.push_str(&overflow);
308    }
309    if !unmatched.is_empty() {
310        if !all_unmatched.is_empty() {
311            all_unmatched.push('\n');
312        }
313        all_unmatched.push_str(unmatched);
314    }
315
316    // Handle unmatched content
317    if !all_unmatched.is_empty() {
318        let unmatched = &all_unmatched;
319        // Re-parse after patches applied
320        let components = component::parse(&result)
321            .context("failed to re-parse components after patching")?;
322
323        if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
324            // Try boundary-aware append first (preserves prompt ordering)
325            if let Some(bid) = find_boundary_in_component(&result, output_comp) {
326                eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
327                result = output_comp.append_with_boundary(&result, unmatched, &bid);
328            } else {
329                // No boundary — plain append to exchange/output component
330                let existing = output_comp.content(&result);
331                let new_content = if existing.trim().is_empty() {
332                    format!("{}\n", unmatched)
333                } else {
334                    format!("{}{}\n", existing, unmatched)
335                };
336                result = output_comp.replace_content(&result, &new_content);
337            }
338        } else {
339            // Auto-create exchange component at the end
340            if !result.ends_with('\n') {
341                result.push('\n');
342            }
343            result.push_str("\n<!-- agent:exchange -->\n");
344            result.push_str(unmatched);
345            result.push_str("\n<!-- /agent:exchange -->\n");
346        }
347    }
348
349    // Post-patch: ensure a boundary exists at the end of the exchange component.
350    // This is unconditional for template docs with an exchange — the boundary must
351    // always exist for checkpoint writes to work. Checking the original doc's content
352    // causes a snowball: once one cycle loses the boundary, every subsequent cycle
353    // also loses it because the check always finds nothing.
354    {
355        if let Ok(components) = component::parse(&result)
356            && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
357            && find_boundary_in_component(&result, exchange).is_none()
358        {
359            // Boundary was consumed — re-insert at end of exchange
360            let id = uuid::Uuid::new_v4().to_string();
361            let marker = format!("<!-- agent:boundary:{} -->", id);
362            let content = exchange.content(&result);
363            let new_content = format!("{}\n{}\n", content.trim_end(), marker);
364            result = exchange.replace_content(&result, &new_content);
365            eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
366        }
367    }
368
369    Ok(result)
370}
371
372/// Reposition the boundary marker to the end of the exchange component.
373///
374/// Removes all existing boundaries and inserts a fresh one at the end of
375/// the exchange. This is the same pre-patch logic used in
376/// `apply_patches_with_overrides()`, extracted for use by the IPC write path.
377///
378/// Returns the document unchanged if no exchange component exists.
379pub fn reposition_boundary_to_end(doc: &str) -> String {
380    reposition_boundary_to_end_with_summary(doc, None)
381}
382
383/// Reposition boundary with an optional human-readable summary suffix.
384///
385/// The summary is slugified and appended to the boundary ID:
386/// `a0cfeb34:agent-doc` instead of just `a0cfeb34`.
387pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
388    let mut result = remove_all_boundaries(doc);
389    if let Ok(components) = component::parse(&result)
390        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
391    {
392        let id = crate::new_boundary_id_with_summary(summary);
393        let marker = crate::format_boundary_marker(&id);
394        let content = exchange.content(&result);
395        let new_content = format!("{}\n{}\n", content.trim_end(), marker);
396        result = exchange.replace_content(&result, &new_content);
397    }
398    result
399}
400
401/// Remove all boundary markers from a document (line-level removal).
402/// Skips boundaries inside fenced code blocks (lesson #13).
403fn remove_all_boundaries(doc: &str) -> String {
404    let prefix = "<!-- agent:boundary:";
405    let suffix = " -->";
406    let code_ranges = component::find_code_ranges(doc);
407    let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
408    let mut result = String::with_capacity(doc.len());
409    let mut offset = 0;
410    for line in doc.lines() {
411        let trimmed = line.trim();
412        let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
413        if is_boundary && !in_code(offset) {
414            // Skip boundary marker lines outside code blocks
415            offset += line.len() + 1; // +1 for newline
416            continue;
417        }
418        result.push_str(line);
419        result.push('\n');
420        offset += line.len() + 1;
421    }
422    if !doc.ends_with('\n') && result.ends_with('\n') {
423        result.pop();
424    }
425    result
426}
427
428/// Find a boundary marker ID inside a component's content, skipping code blocks.
429fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
430    let prefix = "<!-- agent:boundary:";
431    let suffix = " -->";
432    let content_region = &doc[comp.open_end..comp.close_start];
433    let code_ranges = component::find_code_ranges(doc);
434    let mut search_from = 0;
435    while let Some(start) = content_region[search_from..].find(prefix) {
436        let abs_start = comp.open_end + search_from + start;
437        if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
438            search_from += start + prefix.len();
439            continue;
440        }
441        let after_prefix = &content_region[search_from + start + prefix.len()..];
442        if let Some(end) = after_prefix.find(suffix) {
443            return Some(after_prefix[..end].trim().to_string());
444        }
445        break;
446    }
447    None
448}
449
450/// Get template info for a document (for plugin rendering).
451pub fn template_info(file: &Path) -> Result<TemplateInfo> {
452    let doc = std::fs::read_to_string(file)
453        .with_context(|| format!("failed to read {}", file.display()))?;
454
455    let (fm, _body) = crate::frontmatter::parse(&doc)?;
456    let template_mode = fm.resolve_mode().is_template();
457
458    let components = component::parse(&doc)
459        .with_context(|| format!("failed to parse components in {}", file.display()))?;
460
461    let configs = load_component_configs(file);
462
463    let component_infos: Vec<ComponentInfo> = components
464        .iter()
465        .map(|comp| {
466            let content = comp.content(&doc).to_string();
467            // Inline attr > components.toml > built-in default
468            let mode = comp.patch_mode().map(|s| s.to_string())
469                .or_else(|| configs.get(&comp.name).cloned())
470                .unwrap_or_else(|| default_mode(&comp.name).to_string());
471            // Compute line number from byte offset
472            let line = doc[..comp.open_start].matches('\n').count() + 1;
473            ComponentInfo {
474                name: comp.name.clone(),
475                mode,
476                content,
477                line,
478                max_entries: None, // TODO: read from components.toml
479            }
480        })
481        .collect();
482
483    Ok(TemplateInfo {
484        template_mode,
485        components: component_infos,
486    })
487}
488
489/// Load component mode configs from `.agent-doc/components.toml`.
490/// Returns a map of component_name → mode string.
491fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
492    let mut result = std::collections::HashMap::new();
493    let root = find_project_root(file);
494    if let Some(root) = root {
495        let config_path = root.join(".agent-doc/components.toml");
496        if config_path.exists()
497            && let Ok(content) = std::fs::read_to_string(&config_path)
498            && let Ok(table) = content.parse::<toml::Table>()
499        {
500            for (name, value) in &table {
501                // "patch" is the primary key; "mode" is a backward-compatible alias
502                if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
503                    .or_else(|| value.get("mode").and_then(|v| v.as_str()))
504                {
505                    result.insert(name.clone(), mode.to_string());
506                }
507            }
508        }
509    }
510    result
511}
512
513/// Default mode for a component by name.
514/// `exchange` and `findings` default to `append`; all others default to `replace`.
515fn default_mode(name: &str) -> &'static str {
516    match name {
517        "exchange" | "findings" => "append",
518        _ => "replace",
519    }
520}
521
522/// Apply mode logic (replace/append/prepend).
523fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
524    match mode {
525        "append" => format!("{}{}", existing, new_content),
526        "prepend" => format!("{}{}", new_content, existing),
527        _ => new_content.to_string(), // "replace" default
528    }
529}
530
531fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
532    let canonical = file.canonicalize().ok()?;
533    let mut dir = canonical.parent()?;
534    loop {
535        if dir.join(".agent-doc").is_dir() {
536            return Some(dir.to_path_buf());
537        }
538        dir = dir.parent()?;
539    }
540}
541
542/// Find `needle` in `haystack` starting at `from`, skipping occurrences inside code ranges.
543/// Returns the byte offset of the match within `haystack` (absolute, not relative to `from`).
544fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
545    let mut search_start = from;
546    loop {
547        let rel = haystack[search_start..].find(needle)?;
548        let abs = search_start + rel;
549        if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
550            // Inside a code block — skip past this occurrence
551            search_start = abs + needle.len();
552            continue;
553        }
554        return Some(abs);
555    }
556}
557
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use tempfile::TempDir;
563
564    fn setup_project() -> TempDir {
565        let dir = TempDir::new().unwrap();
566        std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
567        dir
568    }
569
570    #[test]
571    fn parse_single_patch() {
572        let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
573        let (patches, unmatched) = parse_patches(response).unwrap();
574        assert_eq!(patches.len(), 1);
575        assert_eq!(patches[0].name, "status");
576        assert_eq!(patches[0].content, "Build passing.\n");
577        assert!(unmatched.is_empty());
578    }
579
580    #[test]
581    fn parse_multiple_patches() {
582        let response = "\
583<!-- patch:status -->
584All green.
585<!-- /patch:status -->
586
587<!-- patch:log -->
588- New entry
589<!-- /patch:log -->
590";
591        let (patches, unmatched) = parse_patches(response).unwrap();
592        assert_eq!(patches.len(), 2);
593        assert_eq!(patches[0].name, "status");
594        assert_eq!(patches[0].content, "All green.\n");
595        assert_eq!(patches[1].name, "log");
596        assert_eq!(patches[1].content, "- New entry\n");
597        assert!(unmatched.is_empty());
598    }
599
600    #[test]
601    fn parse_with_unmatched_content() {
602        let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
603        let (patches, unmatched) = parse_patches(response).unwrap();
604        assert_eq!(patches.len(), 1);
605        assert_eq!(patches[0].name, "status");
606        assert!(unmatched.contains("Some free text."));
607        assert!(unmatched.contains("Trailing text."));
608    }
609
610    #[test]
611    fn parse_empty_response() {
612        let (patches, unmatched) = parse_patches("").unwrap();
613        assert!(patches.is_empty());
614        assert!(unmatched.is_empty());
615    }
616
617    #[test]
618    fn parse_no_patches() {
619        let response = "Just a plain response with no patch blocks.";
620        let (patches, unmatched) = parse_patches(response).unwrap();
621        assert!(patches.is_empty());
622        assert_eq!(unmatched, "Just a plain response with no patch blocks.");
623    }
624
625    #[test]
626    fn apply_patches_replace() {
627        let dir = setup_project();
628        let doc_path = dir.path().join("test.md");
629        let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
630        std::fs::write(&doc_path, doc).unwrap();
631
632        let patches = vec![PatchBlock {
633            name: "status".to_string(),
634            content: "new\n".to_string(),
635        }];
636        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
637        assert!(result.contains("new\n"));
638        assert!(!result.contains("\nold\n"));
639        assert!(result.contains("<!-- agent:status -->"));
640    }
641
642    #[test]
643    fn apply_patches_unmatched_creates_exchange() {
644        let dir = setup_project();
645        let doc_path = dir.path().join("test.md");
646        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
647        std::fs::write(&doc_path, doc).unwrap();
648
649        let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
650        assert!(result.contains("<!-- agent:exchange -->"));
651        assert!(result.contains("Extra info here"));
652        assert!(result.contains("<!-- /agent:exchange -->"));
653    }
654
655    #[test]
656    fn apply_patches_unmatched_appends_to_existing_exchange() {
657        let dir = setup_project();
658        let doc_path = dir.path().join("test.md");
659        let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
660        std::fs::write(&doc_path, doc).unwrap();
661
662        let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
663        assert!(result.contains("previous"));
664        assert!(result.contains("new stuff"));
665        // Should not create a second exchange component
666        assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
667    }
668
669    #[test]
670    fn apply_patches_missing_component_routes_to_exchange() {
671        let dir = setup_project();
672        let doc_path = dir.path().join("test.md");
673        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
674        std::fs::write(&doc_path, doc).unwrap();
675
676        let patches = vec![PatchBlock {
677            name: "nonexistent".to_string(),
678            content: "overflow data\n".to_string(),
679        }];
680        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
681        // Missing component content should be routed to exchange
682        assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
683        assert!(result.contains("previous"), "existing exchange content should be preserved");
684    }
685
686    #[test]
687    fn apply_patches_missing_component_creates_exchange() {
688        let dir = setup_project();
689        let doc_path = dir.path().join("test.md");
690        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
691        std::fs::write(&doc_path, doc).unwrap();
692
693        let patches = vec![PatchBlock {
694            name: "nonexistent".to_string(),
695            content: "overflow data\n".to_string(),
696        }];
697        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
698        // Should auto-create exchange component
699        assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
700        assert!(result.contains("overflow data"), "overflow content should be in exchange");
701    }
702
703    #[test]
704    fn is_template_mode_detection() {
705        assert!(is_template_mode(Some("template")));
706        assert!(!is_template_mode(Some("append")));
707        assert!(!is_template_mode(None));
708    }
709
710    #[test]
711    fn template_info_works() {
712        let dir = setup_project();
713        let doc_path = dir.path().join("test.md");
714        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
715        std::fs::write(&doc_path, doc).unwrap();
716
717        let info = template_info(&doc_path).unwrap();
718        assert!(info.template_mode);
719        assert_eq!(info.components.len(), 1);
720        assert_eq!(info.components[0].name, "status");
721        assert_eq!(info.components[0].content, "content\n");
722    }
723
724    #[test]
725    fn template_info_legacy_mode_works() {
726        let dir = setup_project();
727        let doc_path = dir.path().join("test.md");
728        let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
729        std::fs::write(&doc_path, doc).unwrap();
730
731        let info = template_info(&doc_path).unwrap();
732        assert!(info.template_mode);
733    }
734
735    #[test]
736    fn template_info_append_mode() {
737        let dir = setup_project();
738        let doc_path = dir.path().join("test.md");
739        let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
740        std::fs::write(&doc_path, doc).unwrap();
741
742        let info = template_info(&doc_path).unwrap();
743        assert!(!info.template_mode);
744        assert!(info.components.is_empty());
745    }
746
747    #[test]
748    fn parse_patches_ignores_markers_in_fenced_code_block() {
749        let response = "\
750<!-- patch:exchange -->
751Here is how you use component markers:
752
753```markdown
754<!-- agent:exchange -->
755example content
756<!-- /agent:exchange -->
757```
758
759<!-- /patch:exchange -->
760";
761        let (patches, unmatched) = parse_patches(response).unwrap();
762        assert_eq!(patches.len(), 1);
763        assert_eq!(patches[0].name, "exchange");
764        assert!(patches[0].content.contains("```markdown"));
765        assert!(patches[0].content.contains("<!-- agent:exchange -->"));
766        assert!(unmatched.is_empty());
767    }
768
769    #[test]
770    fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
771        // Patch markers inside a code block should not be treated as real patches
772        let response = "\
773<!-- patch:exchange -->
774Real content here.
775
776```markdown
777<!-- patch:fake -->
778This is just an example.
779<!-- /patch:fake -->
780```
781
782<!-- /patch:exchange -->
783";
784        let (patches, unmatched) = parse_patches(response).unwrap();
785        assert_eq!(patches.len(), 1, "should only find the outer real patch");
786        assert_eq!(patches[0].name, "exchange");
787        assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
788        assert!(unmatched.is_empty());
789    }
790
791    #[test]
792    fn parse_patches_ignores_markers_in_tilde_fence() {
793        let response = "\
794<!-- patch:status -->
795OK
796<!-- /patch:status -->
797
798~~~
799<!-- patch:fake -->
800example
801<!-- /patch:fake -->
802~~~
803";
804        let (patches, _unmatched) = parse_patches(response).unwrap();
805        // Only the real patch should be found; the fake one inside ~~~ is ignored
806        assert_eq!(patches.len(), 1);
807        assert_eq!(patches[0].name, "status");
808    }
809
810    #[test]
811    fn parse_patches_ignores_closing_marker_in_code_block() {
812        // The closing marker for a real patch is inside a code block,
813        // so the parser should skip it and find the real closing marker outside
814        let response = "\
815<!-- patch:exchange -->
816Example:
817
818```
819<!-- /patch:exchange -->
820```
821
822Real content continues.
823<!-- /patch:exchange -->
824";
825        let (patches, unmatched) = parse_patches(response).unwrap();
826        assert_eq!(patches.len(), 1);
827        assert_eq!(patches[0].name, "exchange");
828        assert!(patches[0].content.contains("Real content continues."));
829    }
830
831    #[test]
832    fn parse_patches_normal_markers_still_work() {
833        // Sanity check: normal patch parsing without code blocks still works
834        let response = "\
835<!-- patch:status -->
836All systems go.
837<!-- /patch:status -->
838<!-- patch:log -->
839- Entry 1
840<!-- /patch:log -->
841";
842        let (patches, unmatched) = parse_patches(response).unwrap();
843        assert_eq!(patches.len(), 2);
844        assert_eq!(patches[0].name, "status");
845        assert_eq!(patches[0].content, "All systems go.\n");
846        assert_eq!(patches[1].name, "log");
847        assert_eq!(patches[1].content, "- Entry 1\n");
848        assert!(unmatched.is_empty());
849    }
850
851    // --- Inline attribute mode resolution tests ---
852
853    #[test]
854    fn inline_attr_mode_overrides_config() {
855        // Component has mode=replace inline, but components.toml says append
856        let dir = setup_project();
857        let doc_path = dir.path().join("test.md");
858        // Write config with append mode for status
859        std::fs::write(
860            dir.path().join(".agent-doc/components.toml"),
861            "[status]\nmode = \"append\"\n",
862        ).unwrap();
863        // But the inline attr says replace
864        let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
865        std::fs::write(&doc_path, doc).unwrap();
866
867        let patches = vec![PatchBlock {
868            name: "status".to_string(),
869            content: "new\n".to_string(),
870        }];
871        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
872        // Inline replace should win over config append
873        assert!(result.contains("new\n"));
874        assert!(!result.contains("old\n"));
875    }
876
877    #[test]
878    fn inline_attr_mode_overrides_default() {
879        // exchange defaults to append, but inline says replace
880        let dir = setup_project();
881        let doc_path = dir.path().join("test.md");
882        let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
883        std::fs::write(&doc_path, doc).unwrap();
884
885        let patches = vec![PatchBlock {
886            name: "exchange".to_string(),
887            content: "new\n".to_string(),
888        }];
889        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
890        assert!(result.contains("new\n"));
891        assert!(!result.contains("old\n"));
892    }
893
894    #[test]
895    fn no_inline_attr_falls_back_to_config() {
896        // No inline attr → falls back to components.toml
897        let dir = setup_project();
898        let doc_path = dir.path().join("test.md");
899        std::fs::write(
900            dir.path().join(".agent-doc/components.toml"),
901            "[status]\nmode = \"append\"\n",
902        ).unwrap();
903        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
904        std::fs::write(&doc_path, doc).unwrap();
905
906        let patches = vec![PatchBlock {
907            name: "status".to_string(),
908            content: "new\n".to_string(),
909        }];
910        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
911        // Config says append, so both old and new should be present
912        assert!(result.contains("old\n"));
913        assert!(result.contains("new\n"));
914    }
915
916    #[test]
917    fn no_inline_attr_no_config_falls_back_to_default() {
918        // No inline attr, no config → built-in defaults
919        let dir = setup_project();
920        let doc_path = dir.path().join("test.md");
921        let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
922        std::fs::write(&doc_path, doc).unwrap();
923
924        let patches = vec![PatchBlock {
925            name: "exchange".to_string(),
926            content: "new\n".to_string(),
927        }];
928        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
929        // exchange defaults to append
930        assert!(result.contains("old\n"));
931        assert!(result.contains("new\n"));
932    }
933
934    #[test]
935    fn inline_patch_attr_overrides_config() {
936        // Component has patch=replace inline, but components.toml says append
937        let dir = setup_project();
938        let doc_path = dir.path().join("test.md");
939        std::fs::write(
940            dir.path().join(".agent-doc/components.toml"),
941            "[status]\nmode = \"append\"\n",
942        ).unwrap();
943        let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
944        std::fs::write(&doc_path, doc).unwrap();
945
946        let patches = vec![PatchBlock {
947            name: "status".to_string(),
948            content: "new\n".to_string(),
949        }];
950        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
951        assert!(result.contains("new\n"));
952        assert!(!result.contains("old\n"));
953    }
954
955    #[test]
956    fn inline_patch_attr_overrides_mode_attr() {
957        // Both patch= and mode= present; patch= wins
958        let dir = setup_project();
959        let doc_path = dir.path().join("test.md");
960        let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
961        std::fs::write(&doc_path, doc).unwrap();
962
963        let patches = vec![PatchBlock {
964            name: "exchange".to_string(),
965            content: "new\n".to_string(),
966        }];
967        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
968        assert!(result.contains("new\n"));
969        assert!(!result.contains("old\n"));
970    }
971
972    #[test]
973    fn toml_patch_key_works() {
974        // components.toml uses `patch = "append"` instead of `mode = "append"`
975        let dir = setup_project();
976        let doc_path = dir.path().join("test.md");
977        std::fs::write(
978            dir.path().join(".agent-doc/components.toml"),
979            "[status]\npatch = \"append\"\n",
980        ).unwrap();
981        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
982        std::fs::write(&doc_path, doc).unwrap();
983
984        let patches = vec![PatchBlock {
985            name: "status".to_string(),
986            content: "new\n".to_string(),
987        }];
988        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
989        assert!(result.contains("old\n"));
990        assert!(result.contains("new\n"));
991    }
992
993    #[test]
994    fn stream_override_beats_inline_attr() {
995        // Stream mode overrides should still beat inline attrs
996        let dir = setup_project();
997        let doc_path = dir.path().join("test.md");
998        let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
999        std::fs::write(&doc_path, doc).unwrap();
1000
1001        let patches = vec![PatchBlock {
1002            name: "exchange".to_string(),
1003            content: "new\n".to_string(),
1004        }];
1005        let mut overrides = std::collections::HashMap::new();
1006        overrides.insert("exchange".to_string(), "replace".to_string());
1007        let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1008        // Stream override (replace) should win over inline attr (append)
1009        assert!(result.contains("new\n"));
1010        assert!(!result.contains("old\n"));
1011    }
1012
1013    #[test]
1014    fn apply_patches_ignores_component_tags_in_code_blocks() {
1015        // Component tags inside a fenced code block should not be patch targets.
1016        // Only the real top-level component should receive the patch content.
1017        let dir = setup_project();
1018        let doc_path = dir.path().join("test.md");
1019        let doc = "\
1020# Scaffold Guide
1021
1022Here is an example of a component:
1023
1024```markdown
1025<!-- agent:status -->
1026example scaffold content
1027<!-- /agent:status -->
1028```
1029
1030<!-- agent:status -->
1031real status content
1032<!-- /agent:status -->
1033";
1034        std::fs::write(&doc_path, doc).unwrap();
1035
1036        let patches = vec![PatchBlock {
1037            name: "status".to_string(),
1038            content: "patched status\n".to_string(),
1039        }];
1040        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1041
1042        // The real component should be patched
1043        assert!(result.contains("patched status\n"), "real component should receive the patch");
1044        // The code block example should be untouched
1045        assert!(result.contains("example scaffold content"), "code block content should be preserved");
1046        // The code block's markers should still be there
1047        assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1048    }
1049
1050    #[test]
1051    fn unmatched_content_uses_boundary_marker() {
1052        let dir = setup_project();
1053        let file = dir.path().join("test.md");
1054        let doc = concat!(
1055            "---\nagent_doc_format: template\n---\n",
1056            "<!-- agent:exchange patch=append -->\n",
1057            "User prompt here.\n",
1058            "<!-- agent:boundary:test-uuid-123 -->\n",
1059            "<!-- /agent:exchange -->\n",
1060        );
1061        std::fs::write(&file, doc).unwrap();
1062
1063        // No patch blocks — only unmatched content (simulates skill not wrapping in patch blocks)
1064        let patches = vec![];
1065        let unmatched = "### Re: Response\n\nResponse content here.\n";
1066
1067        let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1068
1069        // Response should be inserted at the boundary marker position (after prompt)
1070        let prompt_pos = result.find("User prompt here.").unwrap();
1071        let response_pos = result.find("### Re: Response").unwrap();
1072        assert!(
1073            response_pos > prompt_pos,
1074            "response should appear after the user prompt (boundary insertion)"
1075        );
1076
1077        // Boundary marker should be consumed (replaced by response)
1078        assert!(
1079            !result.contains("test-uuid-123"),
1080            "boundary marker should be consumed after insertion"
1081        );
1082    }
1083
1084    #[test]
1085    fn explicit_patch_uses_boundary_marker() {
1086        let dir = setup_project();
1087        let file = dir.path().join("test.md");
1088        let doc = concat!(
1089            "---\nagent_doc_format: template\n---\n",
1090            "<!-- agent:exchange patch=append -->\n",
1091            "User prompt here.\n",
1092            "<!-- agent:boundary:patch-uuid-456 -->\n",
1093            "<!-- /agent:exchange -->\n",
1094        );
1095        std::fs::write(&file, doc).unwrap();
1096
1097        // Explicit patch block targeting exchange
1098        let patches = vec![PatchBlock {
1099            name: "exchange".to_string(),
1100            content: "### Re: Response\n\nResponse content.\n".to_string(),
1101        }];
1102
1103        let result = apply_patches(doc, &patches, "", &file).unwrap();
1104
1105        // Response should be after prompt (boundary consumed)
1106        let prompt_pos = result.find("User prompt here.").unwrap();
1107        let response_pos = result.find("### Re: Response").unwrap();
1108        assert!(
1109            response_pos > prompt_pos,
1110            "response should appear after user prompt"
1111        );
1112
1113        // Boundary marker should be consumed
1114        assert!(
1115            !result.contains("patch-uuid-456"),
1116            "boundary marker should be consumed by explicit patch"
1117        );
1118    }
1119
1120    #[test]
1121    fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1122        // Regression: the snowball bug — once one cycle loses the boundary,
1123        // every subsequent cycle also loses it because orig_had_boundary finds nothing.
1124        let dir = setup_project();
1125        let file = dir.path().join("test.md");
1126        // Document with exchange but NO boundary marker
1127        let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1128        std::fs::write(&file, doc).unwrap();
1129
1130        let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1131        let (patches, unmatched) = parse_patches(response).unwrap();
1132        let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1133
1134        // Must have a boundary at end of exchange, even though original had none
1135        assert!(
1136            result.contains("<!-- agent:boundary:"),
1137            "boundary must be re-inserted even when original doc had no boundary: {result}"
1138        );
1139    }
1140
1141    #[test]
1142    fn boundary_survives_multiple_cycles() {
1143        // Simulate two consecutive write cycles — boundary must persist
1144        let dir = setup_project();
1145        let file = dir.path().join("test.md");
1146        let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1147        std::fs::write(&file, doc).unwrap();
1148
1149        // Cycle 1
1150        let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1151        let (patches1, unmatched1) = parse_patches(response1).unwrap();
1152        let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1153        assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1154
1155        // Cycle 2 — use cycle 1's output as the new doc (simulates next write)
1156        let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1157        let (patches2, unmatched2) = parse_patches(response2).unwrap();
1158        let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1159        assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1160    }
1161
1162    #[test]
1163    fn remove_all_boundaries_skips_code_blocks() {
1164        let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1165        let result = remove_all_boundaries(doc);
1166        // The one inside the code block should survive
1167        assert!(
1168            result.contains("<!-- agent:boundary:fake-id -->"),
1169            "boundary inside code block must be preserved: {result}"
1170        );
1171        // The one outside should be removed
1172        assert!(
1173            !result.contains("<!-- agent:boundary:real-id -->"),
1174            "boundary outside code block must be removed: {result}"
1175        );
1176    }
1177
1178    #[test]
1179    fn reposition_boundary_moves_to_end() {
1180        let doc = "\
1181<!-- agent:exchange -->
1182Previous response.
1183<!-- agent:boundary:old-id -->
1184User prompt here.
1185<!-- /agent:exchange -->";
1186        let result = reposition_boundary_to_end(doc);
1187        // Old boundary should be gone
1188        assert!(!result.contains("old-id"), "old boundary should be removed");
1189        // New boundary should exist
1190        assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1191        // New boundary should be after the user prompt, before close tag
1192        let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1193        let prompt_pos = result.find("User prompt here.").unwrap();
1194        let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1195        assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1196        assert!(boundary_pos < close_pos, "boundary should be before close tag");
1197    }
1198
1199    #[test]
1200    fn reposition_boundary_no_exchange_unchanged() {
1201        let doc = "\
1202<!-- agent:output -->
1203Some content.
1204<!-- /agent:output -->";
1205        let result = reposition_boundary_to_end(doc);
1206        assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1207    }
1208}