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;
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        let new_content = apply_mode(mode, comp.content(&result), &patch.content);
206        result = comp.replace_content(&result, &new_content);
207    }
208
209    // Merge overflow (from missing-component patches) with unmatched content
210    let mut all_unmatched = String::new();
211    if !overflow.is_empty() {
212        all_unmatched.push_str(&overflow);
213    }
214    if !unmatched.is_empty() {
215        if !all_unmatched.is_empty() {
216            all_unmatched.push('\n');
217        }
218        all_unmatched.push_str(unmatched);
219    }
220
221    // Handle unmatched content
222    if !all_unmatched.is_empty() {
223        let unmatched = &all_unmatched;
224        // Re-parse after patches applied
225        let components = component::parse(&result)
226            .context("failed to re-parse components after patching")?;
227
228        if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
229            // Append to existing exchange/output component
230            let existing = output_comp.content(&result);
231            let new_content = if existing.trim().is_empty() {
232                format!("{}\n", unmatched)
233            } else {
234                format!("{}{}\n", existing, unmatched)
235            };
236            result = output_comp.replace_content(&result, &new_content);
237        } else {
238            // Auto-create exchange component at the end
239            if !result.ends_with('\n') {
240                result.push('\n');
241            }
242            result.push_str("\n<!-- agent:exchange -->\n");
243            result.push_str(unmatched);
244            result.push_str("\n<!-- /agent:exchange -->\n");
245        }
246    }
247
248    Ok(result)
249}
250
251/// Get template info for a document (for plugin rendering).
252pub fn template_info(file: &Path) -> Result<TemplateInfo> {
253    let doc = std::fs::read_to_string(file)
254        .with_context(|| format!("failed to read {}", file.display()))?;
255
256    let (fm, _body) = crate::frontmatter::parse(&doc)?;
257    let template_mode = fm.resolve_mode().is_template();
258
259    let components = component::parse(&doc)
260        .with_context(|| format!("failed to parse components in {}", file.display()))?;
261
262    let configs = load_component_configs(file);
263
264    let component_infos: Vec<ComponentInfo> = components
265        .iter()
266        .map(|comp| {
267            let content = comp.content(&doc).to_string();
268            // Inline attr > components.toml > built-in default
269            let mode = comp.patch_mode().map(|s| s.to_string())
270                .or_else(|| configs.get(&comp.name).cloned())
271                .unwrap_or_else(|| default_mode(&comp.name).to_string());
272            // Compute line number from byte offset
273            let line = doc[..comp.open_start].matches('\n').count() + 1;
274            ComponentInfo {
275                name: comp.name.clone(),
276                mode,
277                content,
278                line,
279                max_entries: None, // TODO: read from components.toml
280            }
281        })
282        .collect();
283
284    Ok(TemplateInfo {
285        template_mode,
286        components: component_infos,
287    })
288}
289
290/// Load component mode configs from `.agent-doc/components.toml`.
291/// Returns a map of component_name → mode string.
292fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
293    let mut result = std::collections::HashMap::new();
294    let root = find_project_root(file);
295    if let Some(root) = root {
296        let config_path = root.join(".agent-doc/components.toml");
297        if config_path.exists()
298            && let Ok(content) = std::fs::read_to_string(&config_path)
299            && let Ok(table) = content.parse::<toml::Table>()
300        {
301            for (name, value) in &table {
302                // "patch" is the primary key; "mode" is a backward-compatible alias
303                if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
304                    .or_else(|| value.get("mode").and_then(|v| v.as_str()))
305                {
306                    result.insert(name.clone(), mode.to_string());
307                }
308            }
309        }
310    }
311    result
312}
313
314/// Default mode for a component by name.
315/// `exchange` and `findings` default to `append`; all others default to `replace`.
316fn default_mode(name: &str) -> &'static str {
317    match name {
318        "exchange" | "findings" => "append",
319        _ => "replace",
320    }
321}
322
323/// Apply mode logic (replace/append/prepend).
324fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
325    match mode {
326        "append" => format!("{}{}", existing, new_content),
327        "prepend" => format!("{}{}", new_content, existing),
328        _ => new_content.to_string(), // "replace" default
329    }
330}
331
332fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
333    let canonical = file.canonicalize().ok()?;
334    let mut dir = canonical.parent()?;
335    loop {
336        if dir.join(".agent-doc").is_dir() {
337            return Some(dir.to_path_buf());
338        }
339        dir = dir.parent()?;
340    }
341}
342
343/// Find `needle` in `haystack` starting at `from`, skipping occurrences inside code ranges.
344/// Returns the byte offset of the match within `haystack` (absolute, not relative to `from`).
345fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
346    let mut search_start = from;
347    loop {
348        let rel = haystack[search_start..].find(needle)?;
349        let abs = search_start + rel;
350        if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
351            // Inside a code block — skip past this occurrence
352            search_start = abs + needle.len();
353            continue;
354        }
355        return Some(abs);
356    }
357}
358
359fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
360    let len = bytes.len();
361    let mut i = start;
362    while i + 3 <= len {
363        if &bytes[i..i + 3] == b"-->" {
364            return Some(i + 3);
365        }
366        i += 1;
367    }
368    None
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use tempfile::TempDir;
375
376    fn setup_project() -> TempDir {
377        let dir = TempDir::new().unwrap();
378        std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
379        dir
380    }
381
382    #[test]
383    fn parse_single_patch() {
384        let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
385        let (patches, unmatched) = parse_patches(response).unwrap();
386        assert_eq!(patches.len(), 1);
387        assert_eq!(patches[0].name, "status");
388        assert_eq!(patches[0].content, "Build passing.\n");
389        assert!(unmatched.is_empty());
390    }
391
392    #[test]
393    fn parse_multiple_patches() {
394        let response = "\
395<!-- patch:status -->
396All green.
397<!-- /patch:status -->
398
399<!-- patch:log -->
400- New entry
401<!-- /patch:log -->
402";
403        let (patches, unmatched) = parse_patches(response).unwrap();
404        assert_eq!(patches.len(), 2);
405        assert_eq!(patches[0].name, "status");
406        assert_eq!(patches[0].content, "All green.\n");
407        assert_eq!(patches[1].name, "log");
408        assert_eq!(patches[1].content, "- New entry\n");
409        assert!(unmatched.is_empty());
410    }
411
412    #[test]
413    fn parse_with_unmatched_content() {
414        let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
415        let (patches, unmatched) = parse_patches(response).unwrap();
416        assert_eq!(patches.len(), 1);
417        assert_eq!(patches[0].name, "status");
418        assert!(unmatched.contains("Some free text."));
419        assert!(unmatched.contains("Trailing text."));
420    }
421
422    #[test]
423    fn parse_empty_response() {
424        let (patches, unmatched) = parse_patches("").unwrap();
425        assert!(patches.is_empty());
426        assert!(unmatched.is_empty());
427    }
428
429    #[test]
430    fn parse_no_patches() {
431        let response = "Just a plain response with no patch blocks.";
432        let (patches, unmatched) = parse_patches(response).unwrap();
433        assert!(patches.is_empty());
434        assert_eq!(unmatched, "Just a plain response with no patch blocks.");
435    }
436
437    #[test]
438    fn apply_patches_replace() {
439        let dir = setup_project();
440        let doc_path = dir.path().join("test.md");
441        let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
442        std::fs::write(&doc_path, doc).unwrap();
443
444        let patches = vec![PatchBlock {
445            name: "status".to_string(),
446            content: "new\n".to_string(),
447        }];
448        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
449        assert!(result.contains("new\n"));
450        assert!(!result.contains("\nold\n"));
451        assert!(result.contains("<!-- agent:status -->"));
452    }
453
454    #[test]
455    fn apply_patches_unmatched_creates_exchange() {
456        let dir = setup_project();
457        let doc_path = dir.path().join("test.md");
458        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
459        std::fs::write(&doc_path, doc).unwrap();
460
461        let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
462        assert!(result.contains("<!-- agent:exchange -->"));
463        assert!(result.contains("Extra info here"));
464        assert!(result.contains("<!-- /agent:exchange -->"));
465    }
466
467    #[test]
468    fn apply_patches_unmatched_appends_to_existing_exchange() {
469        let dir = setup_project();
470        let doc_path = dir.path().join("test.md");
471        let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
472        std::fs::write(&doc_path, doc).unwrap();
473
474        let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
475        assert!(result.contains("previous"));
476        assert!(result.contains("new stuff"));
477        // Should not create a second exchange component
478        assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
479    }
480
481    #[test]
482    fn apply_patches_missing_component_routes_to_exchange() {
483        let dir = setup_project();
484        let doc_path = dir.path().join("test.md");
485        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
486        std::fs::write(&doc_path, doc).unwrap();
487
488        let patches = vec![PatchBlock {
489            name: "nonexistent".to_string(),
490            content: "overflow data\n".to_string(),
491        }];
492        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
493        // Missing component content should be routed to exchange
494        assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
495        assert!(result.contains("previous"), "existing exchange content should be preserved");
496    }
497
498    #[test]
499    fn apply_patches_missing_component_creates_exchange() {
500        let dir = setup_project();
501        let doc_path = dir.path().join("test.md");
502        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
503        std::fs::write(&doc_path, doc).unwrap();
504
505        let patches = vec![PatchBlock {
506            name: "nonexistent".to_string(),
507            content: "overflow data\n".to_string(),
508        }];
509        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
510        // Should auto-create exchange component
511        assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
512        assert!(result.contains("overflow data"), "overflow content should be in exchange");
513    }
514
515    #[test]
516    fn is_template_mode_detection() {
517        assert!(is_template_mode(Some("template")));
518        assert!(!is_template_mode(Some("append")));
519        assert!(!is_template_mode(None));
520    }
521
522    #[test]
523    fn template_info_works() {
524        let dir = setup_project();
525        let doc_path = dir.path().join("test.md");
526        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
527        std::fs::write(&doc_path, doc).unwrap();
528
529        let info = template_info(&doc_path).unwrap();
530        assert!(info.template_mode);
531        assert_eq!(info.components.len(), 1);
532        assert_eq!(info.components[0].name, "status");
533        assert_eq!(info.components[0].content, "content\n");
534    }
535
536    #[test]
537    fn template_info_legacy_mode_works() {
538        let dir = setup_project();
539        let doc_path = dir.path().join("test.md");
540        let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
541        std::fs::write(&doc_path, doc).unwrap();
542
543        let info = template_info(&doc_path).unwrap();
544        assert!(info.template_mode);
545    }
546
547    #[test]
548    fn template_info_append_mode() {
549        let dir = setup_project();
550        let doc_path = dir.path().join("test.md");
551        let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
552        std::fs::write(&doc_path, doc).unwrap();
553
554        let info = template_info(&doc_path).unwrap();
555        assert!(!info.template_mode);
556        assert!(info.components.is_empty());
557    }
558
559    #[test]
560    fn parse_patches_ignores_markers_in_fenced_code_block() {
561        let response = "\
562<!-- patch:exchange -->
563Here is how you use component markers:
564
565```markdown
566<!-- agent:exchange -->
567example content
568<!-- /agent:exchange -->
569```
570
571<!-- /patch:exchange -->
572";
573        let (patches, unmatched) = parse_patches(response).unwrap();
574        assert_eq!(patches.len(), 1);
575        assert_eq!(patches[0].name, "exchange");
576        assert!(patches[0].content.contains("```markdown"));
577        assert!(patches[0].content.contains("<!-- agent:exchange -->"));
578        assert!(unmatched.is_empty());
579    }
580
581    #[test]
582    fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
583        // Patch markers inside a code block should not be treated as real patches
584        let response = "\
585<!-- patch:exchange -->
586Real content here.
587
588```markdown
589<!-- patch:fake -->
590This is just an example.
591<!-- /patch:fake -->
592```
593
594<!-- /patch:exchange -->
595";
596        let (patches, unmatched) = parse_patches(response).unwrap();
597        assert_eq!(patches.len(), 1, "should only find the outer real patch");
598        assert_eq!(patches[0].name, "exchange");
599        assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
600        assert!(unmatched.is_empty());
601    }
602
603    #[test]
604    fn parse_patches_ignores_markers_in_tilde_fence() {
605        let response = "\
606<!-- patch:status -->
607OK
608<!-- /patch:status -->
609
610~~~
611<!-- patch:fake -->
612example
613<!-- /patch:fake -->
614~~~
615";
616        let (patches, _unmatched) = parse_patches(response).unwrap();
617        // Only the real patch should be found; the fake one inside ~~~ is ignored
618        assert_eq!(patches.len(), 1);
619        assert_eq!(patches[0].name, "status");
620    }
621
622    #[test]
623    fn parse_patches_ignores_closing_marker_in_code_block() {
624        // The closing marker for a real patch is inside a code block,
625        // so the parser should skip it and find the real closing marker outside
626        let response = "\
627<!-- patch:exchange -->
628Example:
629
630```
631<!-- /patch:exchange -->
632```
633
634Real content continues.
635<!-- /patch:exchange -->
636";
637        let (patches, unmatched) = parse_patches(response).unwrap();
638        assert_eq!(patches.len(), 1);
639        assert_eq!(patches[0].name, "exchange");
640        assert!(patches[0].content.contains("Real content continues."));
641    }
642
643    #[test]
644    fn parse_patches_normal_markers_still_work() {
645        // Sanity check: normal patch parsing without code blocks still works
646        let response = "\
647<!-- patch:status -->
648All systems go.
649<!-- /patch:status -->
650<!-- patch:log -->
651- Entry 1
652<!-- /patch:log -->
653";
654        let (patches, unmatched) = parse_patches(response).unwrap();
655        assert_eq!(patches.len(), 2);
656        assert_eq!(patches[0].name, "status");
657        assert_eq!(patches[0].content, "All systems go.\n");
658        assert_eq!(patches[1].name, "log");
659        assert_eq!(patches[1].content, "- Entry 1\n");
660        assert!(unmatched.is_empty());
661    }
662
663    // --- Inline attribute mode resolution tests ---
664
665    #[test]
666    fn inline_attr_mode_overrides_config() {
667        // Component has mode=replace inline, but components.toml says append
668        let dir = setup_project();
669        let doc_path = dir.path().join("test.md");
670        // Write config with append mode for status
671        std::fs::write(
672            dir.path().join(".agent-doc/components.toml"),
673            "[status]\nmode = \"append\"\n",
674        ).unwrap();
675        // But the inline attr says replace
676        let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
677        std::fs::write(&doc_path, doc).unwrap();
678
679        let patches = vec![PatchBlock {
680            name: "status".to_string(),
681            content: "new\n".to_string(),
682        }];
683        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
684        // Inline replace should win over config append
685        assert!(result.contains("new\n"));
686        assert!(!result.contains("old\n"));
687    }
688
689    #[test]
690    fn inline_attr_mode_overrides_default() {
691        // exchange defaults to append, but inline says replace
692        let dir = setup_project();
693        let doc_path = dir.path().join("test.md");
694        let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
695        std::fs::write(&doc_path, doc).unwrap();
696
697        let patches = vec![PatchBlock {
698            name: "exchange".to_string(),
699            content: "new\n".to_string(),
700        }];
701        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
702        assert!(result.contains("new\n"));
703        assert!(!result.contains("old\n"));
704    }
705
706    #[test]
707    fn no_inline_attr_falls_back_to_config() {
708        // No inline attr → falls back to components.toml
709        let dir = setup_project();
710        let doc_path = dir.path().join("test.md");
711        std::fs::write(
712            dir.path().join(".agent-doc/components.toml"),
713            "[status]\nmode = \"append\"\n",
714        ).unwrap();
715        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
716        std::fs::write(&doc_path, doc).unwrap();
717
718        let patches = vec![PatchBlock {
719            name: "status".to_string(),
720            content: "new\n".to_string(),
721        }];
722        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
723        // Config says append, so both old and new should be present
724        assert!(result.contains("old\n"));
725        assert!(result.contains("new\n"));
726    }
727
728    #[test]
729    fn no_inline_attr_no_config_falls_back_to_default() {
730        // No inline attr, no config → built-in defaults
731        let dir = setup_project();
732        let doc_path = dir.path().join("test.md");
733        let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
734        std::fs::write(&doc_path, doc).unwrap();
735
736        let patches = vec![PatchBlock {
737            name: "exchange".to_string(),
738            content: "new\n".to_string(),
739        }];
740        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
741        // exchange defaults to append
742        assert!(result.contains("old\n"));
743        assert!(result.contains("new\n"));
744    }
745
746    #[test]
747    fn inline_patch_attr_overrides_config() {
748        // Component has patch=replace inline, but components.toml says append
749        let dir = setup_project();
750        let doc_path = dir.path().join("test.md");
751        std::fs::write(
752            dir.path().join(".agent-doc/components.toml"),
753            "[status]\nmode = \"append\"\n",
754        ).unwrap();
755        let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
756        std::fs::write(&doc_path, doc).unwrap();
757
758        let patches = vec![PatchBlock {
759            name: "status".to_string(),
760            content: "new\n".to_string(),
761        }];
762        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
763        assert!(result.contains("new\n"));
764        assert!(!result.contains("old\n"));
765    }
766
767    #[test]
768    fn inline_patch_attr_overrides_mode_attr() {
769        // Both patch= and mode= present; patch= wins
770        let dir = setup_project();
771        let doc_path = dir.path().join("test.md");
772        let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
773        std::fs::write(&doc_path, doc).unwrap();
774
775        let patches = vec![PatchBlock {
776            name: "exchange".to_string(),
777            content: "new\n".to_string(),
778        }];
779        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
780        assert!(result.contains("new\n"));
781        assert!(!result.contains("old\n"));
782    }
783
784    #[test]
785    fn toml_patch_key_works() {
786        // components.toml uses `patch = "append"` instead of `mode = "append"`
787        let dir = setup_project();
788        let doc_path = dir.path().join("test.md");
789        std::fs::write(
790            dir.path().join(".agent-doc/components.toml"),
791            "[status]\npatch = \"append\"\n",
792        ).unwrap();
793        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
794        std::fs::write(&doc_path, doc).unwrap();
795
796        let patches = vec![PatchBlock {
797            name: "status".to_string(),
798            content: "new\n".to_string(),
799        }];
800        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
801        assert!(result.contains("old\n"));
802        assert!(result.contains("new\n"));
803    }
804
805    #[test]
806    fn stream_override_beats_inline_attr() {
807        // Stream mode overrides should still beat inline attrs
808        let dir = setup_project();
809        let doc_path = dir.path().join("test.md");
810        let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
811        std::fs::write(&doc_path, doc).unwrap();
812
813        let patches = vec![PatchBlock {
814            name: "exchange".to_string(),
815            content: "new\n".to_string(),
816        }];
817        let mut overrides = std::collections::HashMap::new();
818        overrides.insert("exchange".to_string(), "replace".to_string());
819        let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
820        // Stream override (replace) should win over inline attr (append)
821        assert!(result.contains("new\n"));
822        assert!(!result.contains("old\n"));
823    }
824
825    #[test]
826    fn apply_patches_ignores_component_tags_in_code_blocks() {
827        // Component tags inside a fenced code block should not be patch targets.
828        // Only the real top-level component should receive the patch content.
829        let dir = setup_project();
830        let doc_path = dir.path().join("test.md");
831        let doc = "\
832# Scaffold Guide
833
834Here is an example of a component:
835
836```markdown
837<!-- agent:status -->
838example scaffold content
839<!-- /agent:status -->
840```
841
842<!-- agent:status -->
843real status content
844<!-- /agent:status -->
845";
846        std::fs::write(&doc_path, doc).unwrap();
847
848        let patches = vec![PatchBlock {
849            name: "status".to_string(),
850            content: "patched status\n".to_string(),
851        }];
852        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
853
854        // The real component should be patched
855        assert!(result.contains("patched status\n"), "real component should receive the patch");
856        // The code block example should be untouched
857        assert!(result.contains("example scaffold content"), "code block content should be preserved");
858        // The code block's markers should still be there
859        assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
860    }
861}