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 if mode == "append"
207 && let Some(bid) = find_boundary_in_component(&result, comp)
208 {
209 result = comp.append_with_boundary(&result, &patch.content, &bid);
210 continue;
211 }
212 let new_content = apply_mode(mode, comp.content(&result), &patch.content);
213 result = comp.replace_content(&result, &new_content);
214 }
215
216 let mut all_unmatched = String::new();
218 if !overflow.is_empty() {
219 all_unmatched.push_str(&overflow);
220 }
221 if !unmatched.is_empty() {
222 if !all_unmatched.is_empty() {
223 all_unmatched.push('\n');
224 }
225 all_unmatched.push_str(unmatched);
226 }
227
228 if !all_unmatched.is_empty() {
230 let unmatched = &all_unmatched;
231 let components = component::parse(&result)
233 .context("failed to re-parse components after patching")?;
234
235 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
236 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
238 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
239 result = output_comp.append_with_boundary(&result, unmatched, &bid);
240 } else {
241 let existing = output_comp.content(&result);
243 let new_content = if existing.trim().is_empty() {
244 format!("{}\n", unmatched)
245 } else {
246 format!("{}{}\n", existing, unmatched)
247 };
248 result = output_comp.replace_content(&result, &new_content);
249 }
250 } else {
251 if !result.ends_with('\n') {
253 result.push('\n');
254 }
255 result.push_str("\n<!-- agent:exchange -->\n");
256 result.push_str(unmatched);
257 result.push_str("\n<!-- /agent:exchange -->\n");
258 }
259 }
260
261 Ok(result)
262}
263
264fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
266 let prefix = "<!-- agent:boundary:";
267 let suffix = " -->";
268 let content_region = &doc[comp.open_end..comp.close_start];
269 let code_ranges = component::find_code_ranges(doc);
270 let mut search_from = 0;
271 while let Some(start) = content_region[search_from..].find(prefix) {
272 let abs_start = comp.open_end + search_from + start;
273 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
274 search_from += start + prefix.len();
275 continue;
276 }
277 let after_prefix = &content_region[search_from + start + prefix.len()..];
278 if let Some(end) = after_prefix.find(suffix) {
279 return Some(after_prefix[..end].trim().to_string());
280 }
281 break;
282 }
283 None
284}
285
286pub fn template_info(file: &Path) -> Result<TemplateInfo> {
288 let doc = std::fs::read_to_string(file)
289 .with_context(|| format!("failed to read {}", file.display()))?;
290
291 let (fm, _body) = crate::frontmatter::parse(&doc)?;
292 let template_mode = fm.resolve_mode().is_template();
293
294 let components = component::parse(&doc)
295 .with_context(|| format!("failed to parse components in {}", file.display()))?;
296
297 let configs = load_component_configs(file);
298
299 let component_infos: Vec<ComponentInfo> = components
300 .iter()
301 .map(|comp| {
302 let content = comp.content(&doc).to_string();
303 let mode = comp.patch_mode().map(|s| s.to_string())
305 .or_else(|| configs.get(&comp.name).cloned())
306 .unwrap_or_else(|| default_mode(&comp.name).to_string());
307 let line = doc[..comp.open_start].matches('\n').count() + 1;
309 ComponentInfo {
310 name: comp.name.clone(),
311 mode,
312 content,
313 line,
314 max_entries: None, }
316 })
317 .collect();
318
319 Ok(TemplateInfo {
320 template_mode,
321 components: component_infos,
322 })
323}
324
325fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
328 let mut result = std::collections::HashMap::new();
329 let root = find_project_root(file);
330 if let Some(root) = root {
331 let config_path = root.join(".agent-doc/components.toml");
332 if config_path.exists()
333 && let Ok(content) = std::fs::read_to_string(&config_path)
334 && let Ok(table) = content.parse::<toml::Table>()
335 {
336 for (name, value) in &table {
337 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
339 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
340 {
341 result.insert(name.clone(), mode.to_string());
342 }
343 }
344 }
345 }
346 result
347}
348
349fn default_mode(name: &str) -> &'static str {
352 match name {
353 "exchange" | "findings" => "append",
354 _ => "replace",
355 }
356}
357
358fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
360 match mode {
361 "append" => format!("{}{}", existing, new_content),
362 "prepend" => format!("{}{}", new_content, existing),
363 _ => new_content.to_string(), }
365}
366
367fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
368 let canonical = file.canonicalize().ok()?;
369 let mut dir = canonical.parent()?;
370 loop {
371 if dir.join(".agent-doc").is_dir() {
372 return Some(dir.to_path_buf());
373 }
374 dir = dir.parent()?;
375 }
376}
377
378fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
381 let mut search_start = from;
382 loop {
383 let rel = haystack[search_start..].find(needle)?;
384 let abs = search_start + rel;
385 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
386 search_start = abs + needle.len();
388 continue;
389 }
390 return Some(abs);
391 }
392}
393
394fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
395 let len = bytes.len();
396 let mut i = start;
397 while i + 3 <= len {
398 if &bytes[i..i + 3] == b"-->" {
399 return Some(i + 3);
400 }
401 i += 1;
402 }
403 None
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use tempfile::TempDir;
410
411 fn setup_project() -> TempDir {
412 let dir = TempDir::new().unwrap();
413 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
414 dir
415 }
416
417 #[test]
418 fn parse_single_patch() {
419 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
420 let (patches, unmatched) = parse_patches(response).unwrap();
421 assert_eq!(patches.len(), 1);
422 assert_eq!(patches[0].name, "status");
423 assert_eq!(patches[0].content, "Build passing.\n");
424 assert!(unmatched.is_empty());
425 }
426
427 #[test]
428 fn parse_multiple_patches() {
429 let response = "\
430<!-- patch:status -->
431All green.
432<!-- /patch:status -->
433
434<!-- patch:log -->
435- New entry
436<!-- /patch:log -->
437";
438 let (patches, unmatched) = parse_patches(response).unwrap();
439 assert_eq!(patches.len(), 2);
440 assert_eq!(patches[0].name, "status");
441 assert_eq!(patches[0].content, "All green.\n");
442 assert_eq!(patches[1].name, "log");
443 assert_eq!(patches[1].content, "- New entry\n");
444 assert!(unmatched.is_empty());
445 }
446
447 #[test]
448 fn parse_with_unmatched_content() {
449 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
450 let (patches, unmatched) = parse_patches(response).unwrap();
451 assert_eq!(patches.len(), 1);
452 assert_eq!(patches[0].name, "status");
453 assert!(unmatched.contains("Some free text."));
454 assert!(unmatched.contains("Trailing text."));
455 }
456
457 #[test]
458 fn parse_empty_response() {
459 let (patches, unmatched) = parse_patches("").unwrap();
460 assert!(patches.is_empty());
461 assert!(unmatched.is_empty());
462 }
463
464 #[test]
465 fn parse_no_patches() {
466 let response = "Just a plain response with no patch blocks.";
467 let (patches, unmatched) = parse_patches(response).unwrap();
468 assert!(patches.is_empty());
469 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
470 }
471
472 #[test]
473 fn apply_patches_replace() {
474 let dir = setup_project();
475 let doc_path = dir.path().join("test.md");
476 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
477 std::fs::write(&doc_path, doc).unwrap();
478
479 let patches = vec![PatchBlock {
480 name: "status".to_string(),
481 content: "new\n".to_string(),
482 }];
483 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
484 assert!(result.contains("new\n"));
485 assert!(!result.contains("\nold\n"));
486 assert!(result.contains("<!-- agent:status -->"));
487 }
488
489 #[test]
490 fn apply_patches_unmatched_creates_exchange() {
491 let dir = setup_project();
492 let doc_path = dir.path().join("test.md");
493 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
494 std::fs::write(&doc_path, doc).unwrap();
495
496 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
497 assert!(result.contains("<!-- agent:exchange -->"));
498 assert!(result.contains("Extra info here"));
499 assert!(result.contains("<!-- /agent:exchange -->"));
500 }
501
502 #[test]
503 fn apply_patches_unmatched_appends_to_existing_exchange() {
504 let dir = setup_project();
505 let doc_path = dir.path().join("test.md");
506 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
507 std::fs::write(&doc_path, doc).unwrap();
508
509 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
510 assert!(result.contains("previous"));
511 assert!(result.contains("new stuff"));
512 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
514 }
515
516 #[test]
517 fn apply_patches_missing_component_routes_to_exchange() {
518 let dir = setup_project();
519 let doc_path = dir.path().join("test.md");
520 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
521 std::fs::write(&doc_path, doc).unwrap();
522
523 let patches = vec![PatchBlock {
524 name: "nonexistent".to_string(),
525 content: "overflow data\n".to_string(),
526 }];
527 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
528 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
530 assert!(result.contains("previous"), "existing exchange content should be preserved");
531 }
532
533 #[test]
534 fn apply_patches_missing_component_creates_exchange() {
535 let dir = setup_project();
536 let doc_path = dir.path().join("test.md");
537 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
538 std::fs::write(&doc_path, doc).unwrap();
539
540 let patches = vec![PatchBlock {
541 name: "nonexistent".to_string(),
542 content: "overflow data\n".to_string(),
543 }];
544 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
545 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
547 assert!(result.contains("overflow data"), "overflow content should be in exchange");
548 }
549
550 #[test]
551 fn is_template_mode_detection() {
552 assert!(is_template_mode(Some("template")));
553 assert!(!is_template_mode(Some("append")));
554 assert!(!is_template_mode(None));
555 }
556
557 #[test]
558 fn template_info_works() {
559 let dir = setup_project();
560 let doc_path = dir.path().join("test.md");
561 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
562 std::fs::write(&doc_path, doc).unwrap();
563
564 let info = template_info(&doc_path).unwrap();
565 assert!(info.template_mode);
566 assert_eq!(info.components.len(), 1);
567 assert_eq!(info.components[0].name, "status");
568 assert_eq!(info.components[0].content, "content\n");
569 }
570
571 #[test]
572 fn template_info_legacy_mode_works() {
573 let dir = setup_project();
574 let doc_path = dir.path().join("test.md");
575 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
576 std::fs::write(&doc_path, doc).unwrap();
577
578 let info = template_info(&doc_path).unwrap();
579 assert!(info.template_mode);
580 }
581
582 #[test]
583 fn template_info_append_mode() {
584 let dir = setup_project();
585 let doc_path = dir.path().join("test.md");
586 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
587 std::fs::write(&doc_path, doc).unwrap();
588
589 let info = template_info(&doc_path).unwrap();
590 assert!(!info.template_mode);
591 assert!(info.components.is_empty());
592 }
593
594 #[test]
595 fn parse_patches_ignores_markers_in_fenced_code_block() {
596 let response = "\
597<!-- patch:exchange -->
598Here is how you use component markers:
599
600```markdown
601<!-- agent:exchange -->
602example content
603<!-- /agent:exchange -->
604```
605
606<!-- /patch:exchange -->
607";
608 let (patches, unmatched) = parse_patches(response).unwrap();
609 assert_eq!(patches.len(), 1);
610 assert_eq!(patches[0].name, "exchange");
611 assert!(patches[0].content.contains("```markdown"));
612 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
613 assert!(unmatched.is_empty());
614 }
615
616 #[test]
617 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
618 let response = "\
620<!-- patch:exchange -->
621Real content here.
622
623```markdown
624<!-- patch:fake -->
625This is just an example.
626<!-- /patch:fake -->
627```
628
629<!-- /patch:exchange -->
630";
631 let (patches, unmatched) = parse_patches(response).unwrap();
632 assert_eq!(patches.len(), 1, "should only find the outer real patch");
633 assert_eq!(patches[0].name, "exchange");
634 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
635 assert!(unmatched.is_empty());
636 }
637
638 #[test]
639 fn parse_patches_ignores_markers_in_tilde_fence() {
640 let response = "\
641<!-- patch:status -->
642OK
643<!-- /patch:status -->
644
645~~~
646<!-- patch:fake -->
647example
648<!-- /patch:fake -->
649~~~
650";
651 let (patches, _unmatched) = parse_patches(response).unwrap();
652 assert_eq!(patches.len(), 1);
654 assert_eq!(patches[0].name, "status");
655 }
656
657 #[test]
658 fn parse_patches_ignores_closing_marker_in_code_block() {
659 let response = "\
662<!-- patch:exchange -->
663Example:
664
665```
666<!-- /patch:exchange -->
667```
668
669Real content continues.
670<!-- /patch:exchange -->
671";
672 let (patches, unmatched) = parse_patches(response).unwrap();
673 assert_eq!(patches.len(), 1);
674 assert_eq!(patches[0].name, "exchange");
675 assert!(patches[0].content.contains("Real content continues."));
676 }
677
678 #[test]
679 fn parse_patches_normal_markers_still_work() {
680 let response = "\
682<!-- patch:status -->
683All systems go.
684<!-- /patch:status -->
685<!-- patch:log -->
686- Entry 1
687<!-- /patch:log -->
688";
689 let (patches, unmatched) = parse_patches(response).unwrap();
690 assert_eq!(patches.len(), 2);
691 assert_eq!(patches[0].name, "status");
692 assert_eq!(patches[0].content, "All systems go.\n");
693 assert_eq!(patches[1].name, "log");
694 assert_eq!(patches[1].content, "- Entry 1\n");
695 assert!(unmatched.is_empty());
696 }
697
698 #[test]
701 fn inline_attr_mode_overrides_config() {
702 let dir = setup_project();
704 let doc_path = dir.path().join("test.md");
705 std::fs::write(
707 dir.path().join(".agent-doc/components.toml"),
708 "[status]\nmode = \"append\"\n",
709 ).unwrap();
710 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
712 std::fs::write(&doc_path, doc).unwrap();
713
714 let patches = vec![PatchBlock {
715 name: "status".to_string(),
716 content: "new\n".to_string(),
717 }];
718 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
719 assert!(result.contains("new\n"));
721 assert!(!result.contains("old\n"));
722 }
723
724 #[test]
725 fn inline_attr_mode_overrides_default() {
726 let dir = setup_project();
728 let doc_path = dir.path().join("test.md");
729 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
730 std::fs::write(&doc_path, doc).unwrap();
731
732 let patches = vec![PatchBlock {
733 name: "exchange".to_string(),
734 content: "new\n".to_string(),
735 }];
736 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
737 assert!(result.contains("new\n"));
738 assert!(!result.contains("old\n"));
739 }
740
741 #[test]
742 fn no_inline_attr_falls_back_to_config() {
743 let dir = setup_project();
745 let doc_path = dir.path().join("test.md");
746 std::fs::write(
747 dir.path().join(".agent-doc/components.toml"),
748 "[status]\nmode = \"append\"\n",
749 ).unwrap();
750 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
751 std::fs::write(&doc_path, doc).unwrap();
752
753 let patches = vec![PatchBlock {
754 name: "status".to_string(),
755 content: "new\n".to_string(),
756 }];
757 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
758 assert!(result.contains("old\n"));
760 assert!(result.contains("new\n"));
761 }
762
763 #[test]
764 fn no_inline_attr_no_config_falls_back_to_default() {
765 let dir = setup_project();
767 let doc_path = dir.path().join("test.md");
768 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
769 std::fs::write(&doc_path, doc).unwrap();
770
771 let patches = vec![PatchBlock {
772 name: "exchange".to_string(),
773 content: "new\n".to_string(),
774 }];
775 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
776 assert!(result.contains("old\n"));
778 assert!(result.contains("new\n"));
779 }
780
781 #[test]
782 fn inline_patch_attr_overrides_config() {
783 let dir = setup_project();
785 let doc_path = dir.path().join("test.md");
786 std::fs::write(
787 dir.path().join(".agent-doc/components.toml"),
788 "[status]\nmode = \"append\"\n",
789 ).unwrap();
790 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
791 std::fs::write(&doc_path, doc).unwrap();
792
793 let patches = vec![PatchBlock {
794 name: "status".to_string(),
795 content: "new\n".to_string(),
796 }];
797 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
798 assert!(result.contains("new\n"));
799 assert!(!result.contains("old\n"));
800 }
801
802 #[test]
803 fn inline_patch_attr_overrides_mode_attr() {
804 let dir = setup_project();
806 let doc_path = dir.path().join("test.md");
807 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
808 std::fs::write(&doc_path, doc).unwrap();
809
810 let patches = vec![PatchBlock {
811 name: "exchange".to_string(),
812 content: "new\n".to_string(),
813 }];
814 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
815 assert!(result.contains("new\n"));
816 assert!(!result.contains("old\n"));
817 }
818
819 #[test]
820 fn toml_patch_key_works() {
821 let dir = setup_project();
823 let doc_path = dir.path().join("test.md");
824 std::fs::write(
825 dir.path().join(".agent-doc/components.toml"),
826 "[status]\npatch = \"append\"\n",
827 ).unwrap();
828 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
829 std::fs::write(&doc_path, doc).unwrap();
830
831 let patches = vec![PatchBlock {
832 name: "status".to_string(),
833 content: "new\n".to_string(),
834 }];
835 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
836 assert!(result.contains("old\n"));
837 assert!(result.contains("new\n"));
838 }
839
840 #[test]
841 fn stream_override_beats_inline_attr() {
842 let dir = setup_project();
844 let doc_path = dir.path().join("test.md");
845 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
846 std::fs::write(&doc_path, doc).unwrap();
847
848 let patches = vec![PatchBlock {
849 name: "exchange".to_string(),
850 content: "new\n".to_string(),
851 }];
852 let mut overrides = std::collections::HashMap::new();
853 overrides.insert("exchange".to_string(), "replace".to_string());
854 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
855 assert!(result.contains("new\n"));
857 assert!(!result.contains("old\n"));
858 }
859
860 #[test]
861 fn apply_patches_ignores_component_tags_in_code_blocks() {
862 let dir = setup_project();
865 let doc_path = dir.path().join("test.md");
866 let doc = "\
867# Scaffold Guide
868
869Here is an example of a component:
870
871```markdown
872<!-- agent:status -->
873example scaffold content
874<!-- /agent:status -->
875```
876
877<!-- agent:status -->
878real status content
879<!-- /agent:status -->
880";
881 std::fs::write(&doc_path, doc).unwrap();
882
883 let patches = vec![PatchBlock {
884 name: "status".to_string(),
885 content: "patched status\n".to_string(),
886 }];
887 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
888
889 assert!(result.contains("patched status\n"), "real component should receive the patch");
891 assert!(result.contains("example scaffold content"), "code block content should be preserved");
893 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
895 }
896
897 #[test]
898 fn unmatched_content_uses_boundary_marker() {
899 let dir = setup_project();
900 let file = dir.path().join("test.md");
901 let doc = concat!(
902 "---\nagent_doc_format: template\n---\n",
903 "<!-- agent:exchange patch=append -->\n",
904 "User prompt here.\n",
905 "<!-- agent:boundary:test-uuid-123 -->\n",
906 "<!-- /agent:exchange -->\n",
907 );
908 std::fs::write(&file, doc).unwrap();
909
910 let patches = vec![];
912 let unmatched = "### Re: Response\n\nResponse content here.\n";
913
914 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
915
916 let prompt_pos = result.find("User prompt here.").unwrap();
918 let response_pos = result.find("### Re: Response").unwrap();
919 assert!(
920 response_pos > prompt_pos,
921 "response should appear after the user prompt (boundary insertion)"
922 );
923
924 assert!(
926 !result.contains("test-uuid-123"),
927 "boundary marker should be consumed after insertion"
928 );
929 }
930
931 #[test]
932 fn explicit_patch_uses_boundary_marker() {
933 let dir = setup_project();
934 let file = dir.path().join("test.md");
935 let doc = concat!(
936 "---\nagent_doc_format: template\n---\n",
937 "<!-- agent:exchange patch=append -->\n",
938 "User prompt here.\n",
939 "<!-- agent:boundary:patch-uuid-456 -->\n",
940 "<!-- /agent:exchange -->\n",
941 );
942 std::fs::write(&file, doc).unwrap();
943
944 let patches = vec![PatchBlock {
946 name: "exchange".to_string(),
947 content: "### Re: Response\n\nResponse content.\n".to_string(),
948 }];
949
950 let result = apply_patches(doc, &patches, "", &file).unwrap();
951
952 let prompt_pos = result.find("User prompt here.").unwrap();
954 let response_pos = result.find("### Re: Response").unwrap();
955 assert!(
956 response_pos > prompt_pos,
957 "response should appear after user prompt"
958 );
959
960 assert!(
962 !result.contains("patch-uuid-456"),
963 "boundary marker should be consumed by explicit patch"
964 );
965 }
966}