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