1use anyhow::{Context, Result};
7use serde::Serialize;
8use std::path::Path;
9
10use crate::component::{self, 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 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 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 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
257fn 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
279pub 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 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 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, }
309 })
310 .collect();
311
312 Ok(TemplateInfo {
313 template_mode,
314 components: component_infos,
315 })
316}
317
318fn 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 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
342fn default_mode(name: &str) -> &'static str {
345 match name {
346 "exchange" | "findings" => "append",
347 _ => "replace",
348 }
349}
350
351fn 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(), }
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
371fn 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 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 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 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 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 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 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 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 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 #[test]
694 fn inline_attr_mode_overrides_config() {
695 let dir = setup_project();
697 let doc_path = dir.path().join("test.md");
698 std::fs::write(
700 dir.path().join(".agent-doc/components.toml"),
701 "[status]\nmode = \"append\"\n",
702 ).unwrap();
703 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 assert!(result.contains("new\n"));
714 assert!(!result.contains("old\n"));
715 }
716
717 #[test]
718 fn inline_attr_mode_overrides_default() {
719 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 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 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 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 assert!(result.contains("old\n"));
771 assert!(result.contains("new\n"));
772 }
773
774 #[test]
775 fn inline_patch_attr_overrides_config() {
776 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 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 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 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 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 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 assert!(result.contains("patched status\n"), "real component should receive the patch");
884 assert!(result.contains("example scaffold content"), "code block content should be preserved");
886 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 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 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 assert!(
919 !result.contains("test-uuid-123"),
920 "boundary marker should be consumed after insertion"
921 );
922 }
923}