Skip to main content

agent_doc/
template.rs

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