1use anyhow::{Context, Result};
7use serde::Serialize;
8use std::path::Path;
9
10use crate::component;
11
12#[derive(Debug, Clone)]
14pub struct PatchBlock {
15 pub name: String,
16 pub content: String,
17}
18
19#[derive(Debug, Serialize)]
21pub struct TemplateInfo {
22 pub template_mode: bool,
23 pub components: Vec<ComponentInfo>,
24}
25
26#[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#[cfg(test)]
39pub fn is_template_mode(mode: Option<&str>) -> bool {
40 matches!(mode, Some("template"))
41}
42
43pub 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 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 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 let mut content_start = close;
91 if content_start < len && bytes[content_start] == b'\n' {
92 content_start += 1;
93 }
94
95 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 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 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
141pub 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
153pub 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 let components = component::parse(&result)
166 .context("failed to parse components")?;
167
168 let configs = load_component_configs(file);
170
171 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 ops.sort_by(|a, b| b.0.cmp(&a.0));
196
197 for (idx, patch) in &ops {
198 let comp = &components[*idx];
199 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 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 if !all_unmatched.is_empty() {
223 let unmatched = &all_unmatched;
224 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 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 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
251pub 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 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 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, }
281 })
282 .collect();
283
284 Ok(TemplateInfo {
285 template_mode,
286 components: component_infos,
287 })
288}
289
290fn 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 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
314fn default_mode(name: &str) -> &'static str {
317 match name {
318 "exchange" | "findings" => "append",
319 _ => "replace",
320 }
321}
322
323fn 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(), }
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
343fn 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 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 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 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 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 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 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 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 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 #[test]
666 fn inline_attr_mode_overrides_config() {
667 let dir = setup_project();
669 let doc_path = dir.path().join("test.md");
670 std::fs::write(
672 dir.path().join(".agent-doc/components.toml"),
673 "[status]\nmode = \"append\"\n",
674 ).unwrap();
675 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 assert!(result.contains("new\n"));
686 assert!(!result.contains("old\n"));
687 }
688
689 #[test]
690 fn inline_attr_mode_overrides_default() {
691 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 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 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 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 assert!(result.contains("old\n"));
743 assert!(result.contains("new\n"));
744 }
745
746 #[test]
747 fn inline_patch_attr_overrides_config() {
748 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 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 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 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 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 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 assert!(result.contains("patched status\n"), "real component should receive the patch");
856 assert!(result.contains("example scaffold content"), "code block content should be preserved");
858 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
860 }
861}