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