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