1use anyhow::{Context, Result};
7use serde::Serialize;
8use std::path::Path;
9
10use crate::component::{self, find_comment_end, 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 summary = file.file_stem().and_then(|s| s.to_str());
167 let mut result = remove_all_boundaries(doc);
168 if let Ok(components) = component::parse(&result)
169 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
170 {
171 let id = crate::new_boundary_id_with_summary(summary);
172 let marker = crate::format_boundary_marker(&id);
173 let content = exchange.content(&result);
174 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
175 result = exchange.replace_content(&result, &new_content);
176 eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
177 }
178
179 let components = component::parse(&result)
181 .context("failed to parse components")?;
182
183 let configs = load_component_configs(file);
185
186 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
191 let mut overflow = String::new();
192 for patch in patches {
193 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
194 ops.push((idx, patch));
195 } else {
196 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
197 eprintln!(
198 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
199 patch.name,
200 available.join(", ")
201 );
202 if !overflow.is_empty() {
203 overflow.push('\n');
204 }
205 overflow.push_str(&patch.content);
206 }
207 }
208
209 ops.sort_by(|a, b| b.0.cmp(&a.0));
211
212 for (idx, patch) in &ops {
213 let comp = &components[*idx];
214 let mode = mode_overrides.get(&patch.name)
216 .map(|s| s.as_str())
217 .or_else(|| comp.patch_mode())
218 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
219 .unwrap_or_else(|| default_mode(&patch.name));
220 if mode == "append"
222 && let Some(bid) = find_boundary_in_component(&result, comp)
223 {
224 result = comp.append_with_boundary(&result, &patch.content, &bid);
225 continue;
226 }
227 let new_content = apply_mode(mode, comp.content(&result), &patch.content);
228 result = comp.replace_content(&result, &new_content);
229 }
230
231 let mut all_unmatched = String::new();
233 if !overflow.is_empty() {
234 all_unmatched.push_str(&overflow);
235 }
236 if !unmatched.is_empty() {
237 if !all_unmatched.is_empty() {
238 all_unmatched.push('\n');
239 }
240 all_unmatched.push_str(unmatched);
241 }
242
243 if !all_unmatched.is_empty() {
245 let unmatched = &all_unmatched;
246 let components = component::parse(&result)
248 .context("failed to re-parse components after patching")?;
249
250 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
251 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
253 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
254 result = output_comp.append_with_boundary(&result, unmatched, &bid);
255 } else {
256 let existing = output_comp.content(&result);
258 let new_content = if existing.trim().is_empty() {
259 format!("{}\n", unmatched)
260 } else {
261 format!("{}{}\n", existing, unmatched)
262 };
263 result = output_comp.replace_content(&result, &new_content);
264 }
265 } else {
266 if !result.ends_with('\n') {
268 result.push('\n');
269 }
270 result.push_str("\n<!-- agent:exchange -->\n");
271 result.push_str(unmatched);
272 result.push_str("\n<!-- /agent:exchange -->\n");
273 }
274 }
275
276 {
282 if let Ok(components) = component::parse(&result)
283 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
284 && find_boundary_in_component(&result, exchange).is_none()
285 {
286 let id = uuid::Uuid::new_v4().to_string();
288 let marker = format!("<!-- agent:boundary:{} -->", id);
289 let content = exchange.content(&result);
290 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
291 result = exchange.replace_content(&result, &new_content);
292 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
293 }
294 }
295
296 Ok(result)
297}
298
299pub fn reposition_boundary_to_end(doc: &str) -> String {
307 reposition_boundary_to_end_with_summary(doc, None)
308}
309
310pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
315 let mut result = remove_all_boundaries(doc);
316 if let Ok(components) = component::parse(&result)
317 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
318 {
319 let id = crate::new_boundary_id_with_summary(summary);
320 let marker = crate::format_boundary_marker(&id);
321 let content = exchange.content(&result);
322 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
323 result = exchange.replace_content(&result, &new_content);
324 }
325 result
326}
327
328fn remove_all_boundaries(doc: &str) -> String {
331 let prefix = "<!-- agent:boundary:";
332 let suffix = " -->";
333 let code_ranges = component::find_code_ranges(doc);
334 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
335 let mut result = String::with_capacity(doc.len());
336 let mut offset = 0;
337 for line in doc.lines() {
338 let trimmed = line.trim();
339 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
340 if is_boundary && !in_code(offset) {
341 offset += line.len() + 1; continue;
344 }
345 result.push_str(line);
346 result.push('\n');
347 offset += line.len() + 1;
348 }
349 if !doc.ends_with('\n') && result.ends_with('\n') {
350 result.pop();
351 }
352 result
353}
354
355fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
357 let prefix = "<!-- agent:boundary:";
358 let suffix = " -->";
359 let content_region = &doc[comp.open_end..comp.close_start];
360 let code_ranges = component::find_code_ranges(doc);
361 let mut search_from = 0;
362 while let Some(start) = content_region[search_from..].find(prefix) {
363 let abs_start = comp.open_end + search_from + start;
364 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
365 search_from += start + prefix.len();
366 continue;
367 }
368 let after_prefix = &content_region[search_from + start + prefix.len()..];
369 if let Some(end) = after_prefix.find(suffix) {
370 return Some(after_prefix[..end].trim().to_string());
371 }
372 break;
373 }
374 None
375}
376
377pub fn template_info(file: &Path) -> Result<TemplateInfo> {
379 let doc = std::fs::read_to_string(file)
380 .with_context(|| format!("failed to read {}", file.display()))?;
381
382 let (fm, _body) = crate::frontmatter::parse(&doc)?;
383 let template_mode = fm.resolve_mode().is_template();
384
385 let components = component::parse(&doc)
386 .with_context(|| format!("failed to parse components in {}", file.display()))?;
387
388 let configs = load_component_configs(file);
389
390 let component_infos: Vec<ComponentInfo> = components
391 .iter()
392 .map(|comp| {
393 let content = comp.content(&doc).to_string();
394 let mode = comp.patch_mode().map(|s| s.to_string())
396 .or_else(|| configs.get(&comp.name).cloned())
397 .unwrap_or_else(|| default_mode(&comp.name).to_string());
398 let line = doc[..comp.open_start].matches('\n').count() + 1;
400 ComponentInfo {
401 name: comp.name.clone(),
402 mode,
403 content,
404 line,
405 max_entries: None, }
407 })
408 .collect();
409
410 Ok(TemplateInfo {
411 template_mode,
412 components: component_infos,
413 })
414}
415
416fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
419 let mut result = std::collections::HashMap::new();
420 let root = find_project_root(file);
421 if let Some(root) = root {
422 let config_path = root.join(".agent-doc/components.toml");
423 if config_path.exists()
424 && let Ok(content) = std::fs::read_to_string(&config_path)
425 && let Ok(table) = content.parse::<toml::Table>()
426 {
427 for (name, value) in &table {
428 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
430 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
431 {
432 result.insert(name.clone(), mode.to_string());
433 }
434 }
435 }
436 }
437 result
438}
439
440fn default_mode(name: &str) -> &'static str {
443 match name {
444 "exchange" | "findings" => "append",
445 _ => "replace",
446 }
447}
448
449fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
451 match mode {
452 "append" => format!("{}{}", existing, new_content),
453 "prepend" => format!("{}{}", new_content, existing),
454 _ => new_content.to_string(), }
456}
457
458fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
459 let canonical = file.canonicalize().ok()?;
460 let mut dir = canonical.parent()?;
461 loop {
462 if dir.join(".agent-doc").is_dir() {
463 return Some(dir.to_path_buf());
464 }
465 dir = dir.parent()?;
466 }
467}
468
469fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
472 let mut search_start = from;
473 loop {
474 let rel = haystack[search_start..].find(needle)?;
475 let abs = search_start + rel;
476 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
477 search_start = abs + needle.len();
479 continue;
480 }
481 return Some(abs);
482 }
483}
484
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use tempfile::TempDir;
490
491 fn setup_project() -> TempDir {
492 let dir = TempDir::new().unwrap();
493 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
494 dir
495 }
496
497 #[test]
498 fn parse_single_patch() {
499 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
500 let (patches, unmatched) = parse_patches(response).unwrap();
501 assert_eq!(patches.len(), 1);
502 assert_eq!(patches[0].name, "status");
503 assert_eq!(patches[0].content, "Build passing.\n");
504 assert!(unmatched.is_empty());
505 }
506
507 #[test]
508 fn parse_multiple_patches() {
509 let response = "\
510<!-- patch:status -->
511All green.
512<!-- /patch:status -->
513
514<!-- patch:log -->
515- New entry
516<!-- /patch:log -->
517";
518 let (patches, unmatched) = parse_patches(response).unwrap();
519 assert_eq!(patches.len(), 2);
520 assert_eq!(patches[0].name, "status");
521 assert_eq!(patches[0].content, "All green.\n");
522 assert_eq!(patches[1].name, "log");
523 assert_eq!(patches[1].content, "- New entry\n");
524 assert!(unmatched.is_empty());
525 }
526
527 #[test]
528 fn parse_with_unmatched_content() {
529 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
530 let (patches, unmatched) = parse_patches(response).unwrap();
531 assert_eq!(patches.len(), 1);
532 assert_eq!(patches[0].name, "status");
533 assert!(unmatched.contains("Some free text."));
534 assert!(unmatched.contains("Trailing text."));
535 }
536
537 #[test]
538 fn parse_empty_response() {
539 let (patches, unmatched) = parse_patches("").unwrap();
540 assert!(patches.is_empty());
541 assert!(unmatched.is_empty());
542 }
543
544 #[test]
545 fn parse_no_patches() {
546 let response = "Just a plain response with no patch blocks.";
547 let (patches, unmatched) = parse_patches(response).unwrap();
548 assert!(patches.is_empty());
549 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
550 }
551
552 #[test]
553 fn apply_patches_replace() {
554 let dir = setup_project();
555 let doc_path = dir.path().join("test.md");
556 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
557 std::fs::write(&doc_path, doc).unwrap();
558
559 let patches = vec![PatchBlock {
560 name: "status".to_string(),
561 content: "new\n".to_string(),
562 }];
563 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
564 assert!(result.contains("new\n"));
565 assert!(!result.contains("\nold\n"));
566 assert!(result.contains("<!-- agent:status -->"));
567 }
568
569 #[test]
570 fn apply_patches_unmatched_creates_exchange() {
571 let dir = setup_project();
572 let doc_path = dir.path().join("test.md");
573 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
574 std::fs::write(&doc_path, doc).unwrap();
575
576 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
577 assert!(result.contains("<!-- agent:exchange -->"));
578 assert!(result.contains("Extra info here"));
579 assert!(result.contains("<!-- /agent:exchange -->"));
580 }
581
582 #[test]
583 fn apply_patches_unmatched_appends_to_existing_exchange() {
584 let dir = setup_project();
585 let doc_path = dir.path().join("test.md");
586 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
587 std::fs::write(&doc_path, doc).unwrap();
588
589 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
590 assert!(result.contains("previous"));
591 assert!(result.contains("new stuff"));
592 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
594 }
595
596 #[test]
597 fn apply_patches_missing_component_routes_to_exchange() {
598 let dir = setup_project();
599 let doc_path = dir.path().join("test.md");
600 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
601 std::fs::write(&doc_path, doc).unwrap();
602
603 let patches = vec![PatchBlock {
604 name: "nonexistent".to_string(),
605 content: "overflow data\n".to_string(),
606 }];
607 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
608 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
610 assert!(result.contains("previous"), "existing exchange content should be preserved");
611 }
612
613 #[test]
614 fn apply_patches_missing_component_creates_exchange() {
615 let dir = setup_project();
616 let doc_path = dir.path().join("test.md");
617 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
618 std::fs::write(&doc_path, doc).unwrap();
619
620 let patches = vec![PatchBlock {
621 name: "nonexistent".to_string(),
622 content: "overflow data\n".to_string(),
623 }];
624 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
625 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
627 assert!(result.contains("overflow data"), "overflow content should be in exchange");
628 }
629
630 #[test]
631 fn is_template_mode_detection() {
632 assert!(is_template_mode(Some("template")));
633 assert!(!is_template_mode(Some("append")));
634 assert!(!is_template_mode(None));
635 }
636
637 #[test]
638 fn template_info_works() {
639 let dir = setup_project();
640 let doc_path = dir.path().join("test.md");
641 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
642 std::fs::write(&doc_path, doc).unwrap();
643
644 let info = template_info(&doc_path).unwrap();
645 assert!(info.template_mode);
646 assert_eq!(info.components.len(), 1);
647 assert_eq!(info.components[0].name, "status");
648 assert_eq!(info.components[0].content, "content\n");
649 }
650
651 #[test]
652 fn template_info_legacy_mode_works() {
653 let dir = setup_project();
654 let doc_path = dir.path().join("test.md");
655 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
656 std::fs::write(&doc_path, doc).unwrap();
657
658 let info = template_info(&doc_path).unwrap();
659 assert!(info.template_mode);
660 }
661
662 #[test]
663 fn template_info_append_mode() {
664 let dir = setup_project();
665 let doc_path = dir.path().join("test.md");
666 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
667 std::fs::write(&doc_path, doc).unwrap();
668
669 let info = template_info(&doc_path).unwrap();
670 assert!(!info.template_mode);
671 assert!(info.components.is_empty());
672 }
673
674 #[test]
675 fn parse_patches_ignores_markers_in_fenced_code_block() {
676 let response = "\
677<!-- patch:exchange -->
678Here is how you use component markers:
679
680```markdown
681<!-- agent:exchange -->
682example content
683<!-- /agent:exchange -->
684```
685
686<!-- /patch:exchange -->
687";
688 let (patches, unmatched) = parse_patches(response).unwrap();
689 assert_eq!(patches.len(), 1);
690 assert_eq!(patches[0].name, "exchange");
691 assert!(patches[0].content.contains("```markdown"));
692 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
693 assert!(unmatched.is_empty());
694 }
695
696 #[test]
697 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
698 let response = "\
700<!-- patch:exchange -->
701Real content here.
702
703```markdown
704<!-- patch:fake -->
705This is just an example.
706<!-- /patch:fake -->
707```
708
709<!-- /patch:exchange -->
710";
711 let (patches, unmatched) = parse_patches(response).unwrap();
712 assert_eq!(patches.len(), 1, "should only find the outer real patch");
713 assert_eq!(patches[0].name, "exchange");
714 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
715 assert!(unmatched.is_empty());
716 }
717
718 #[test]
719 fn parse_patches_ignores_markers_in_tilde_fence() {
720 let response = "\
721<!-- patch:status -->
722OK
723<!-- /patch:status -->
724
725~~~
726<!-- patch:fake -->
727example
728<!-- /patch:fake -->
729~~~
730";
731 let (patches, _unmatched) = parse_patches(response).unwrap();
732 assert_eq!(patches.len(), 1);
734 assert_eq!(patches[0].name, "status");
735 }
736
737 #[test]
738 fn parse_patches_ignores_closing_marker_in_code_block() {
739 let response = "\
742<!-- patch:exchange -->
743Example:
744
745```
746<!-- /patch:exchange -->
747```
748
749Real content continues.
750<!-- /patch:exchange -->
751";
752 let (patches, unmatched) = parse_patches(response).unwrap();
753 assert_eq!(patches.len(), 1);
754 assert_eq!(patches[0].name, "exchange");
755 assert!(patches[0].content.contains("Real content continues."));
756 }
757
758 #[test]
759 fn parse_patches_normal_markers_still_work() {
760 let response = "\
762<!-- patch:status -->
763All systems go.
764<!-- /patch:status -->
765<!-- patch:log -->
766- Entry 1
767<!-- /patch:log -->
768";
769 let (patches, unmatched) = parse_patches(response).unwrap();
770 assert_eq!(patches.len(), 2);
771 assert_eq!(patches[0].name, "status");
772 assert_eq!(patches[0].content, "All systems go.\n");
773 assert_eq!(patches[1].name, "log");
774 assert_eq!(patches[1].content, "- Entry 1\n");
775 assert!(unmatched.is_empty());
776 }
777
778 #[test]
781 fn inline_attr_mode_overrides_config() {
782 let dir = setup_project();
784 let doc_path = dir.path().join("test.md");
785 std::fs::write(
787 dir.path().join(".agent-doc/components.toml"),
788 "[status]\nmode = \"append\"\n",
789 ).unwrap();
790 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
792 std::fs::write(&doc_path, doc).unwrap();
793
794 let patches = vec![PatchBlock {
795 name: "status".to_string(),
796 content: "new\n".to_string(),
797 }];
798 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
799 assert!(result.contains("new\n"));
801 assert!(!result.contains("old\n"));
802 }
803
804 #[test]
805 fn inline_attr_mode_overrides_default() {
806 let dir = setup_project();
808 let doc_path = dir.path().join("test.md");
809 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
810 std::fs::write(&doc_path, doc).unwrap();
811
812 let patches = vec![PatchBlock {
813 name: "exchange".to_string(),
814 content: "new\n".to_string(),
815 }];
816 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
817 assert!(result.contains("new\n"));
818 assert!(!result.contains("old\n"));
819 }
820
821 #[test]
822 fn no_inline_attr_falls_back_to_config() {
823 let dir = setup_project();
825 let doc_path = dir.path().join("test.md");
826 std::fs::write(
827 dir.path().join(".agent-doc/components.toml"),
828 "[status]\nmode = \"append\"\n",
829 ).unwrap();
830 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
831 std::fs::write(&doc_path, doc).unwrap();
832
833 let patches = vec![PatchBlock {
834 name: "status".to_string(),
835 content: "new\n".to_string(),
836 }];
837 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
838 assert!(result.contains("old\n"));
840 assert!(result.contains("new\n"));
841 }
842
843 #[test]
844 fn no_inline_attr_no_config_falls_back_to_default() {
845 let dir = setup_project();
847 let doc_path = dir.path().join("test.md");
848 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
849 std::fs::write(&doc_path, doc).unwrap();
850
851 let patches = vec![PatchBlock {
852 name: "exchange".to_string(),
853 content: "new\n".to_string(),
854 }];
855 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
856 assert!(result.contains("old\n"));
858 assert!(result.contains("new\n"));
859 }
860
861 #[test]
862 fn inline_patch_attr_overrides_config() {
863 let dir = setup_project();
865 let doc_path = dir.path().join("test.md");
866 std::fs::write(
867 dir.path().join(".agent-doc/components.toml"),
868 "[status]\nmode = \"append\"\n",
869 ).unwrap();
870 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
871 std::fs::write(&doc_path, doc).unwrap();
872
873 let patches = vec![PatchBlock {
874 name: "status".to_string(),
875 content: "new\n".to_string(),
876 }];
877 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
878 assert!(result.contains("new\n"));
879 assert!(!result.contains("old\n"));
880 }
881
882 #[test]
883 fn inline_patch_attr_overrides_mode_attr() {
884 let dir = setup_project();
886 let doc_path = dir.path().join("test.md");
887 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
888 std::fs::write(&doc_path, doc).unwrap();
889
890 let patches = vec![PatchBlock {
891 name: "exchange".to_string(),
892 content: "new\n".to_string(),
893 }];
894 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
895 assert!(result.contains("new\n"));
896 assert!(!result.contains("old\n"));
897 }
898
899 #[test]
900 fn toml_patch_key_works() {
901 let dir = setup_project();
903 let doc_path = dir.path().join("test.md");
904 std::fs::write(
905 dir.path().join(".agent-doc/components.toml"),
906 "[status]\npatch = \"append\"\n",
907 ).unwrap();
908 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
909 std::fs::write(&doc_path, doc).unwrap();
910
911 let patches = vec![PatchBlock {
912 name: "status".to_string(),
913 content: "new\n".to_string(),
914 }];
915 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
916 assert!(result.contains("old\n"));
917 assert!(result.contains("new\n"));
918 }
919
920 #[test]
921 fn stream_override_beats_inline_attr() {
922 let dir = setup_project();
924 let doc_path = dir.path().join("test.md");
925 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
926 std::fs::write(&doc_path, doc).unwrap();
927
928 let patches = vec![PatchBlock {
929 name: "exchange".to_string(),
930 content: "new\n".to_string(),
931 }];
932 let mut overrides = std::collections::HashMap::new();
933 overrides.insert("exchange".to_string(), "replace".to_string());
934 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
935 assert!(result.contains("new\n"));
937 assert!(!result.contains("old\n"));
938 }
939
940 #[test]
941 fn apply_patches_ignores_component_tags_in_code_blocks() {
942 let dir = setup_project();
945 let doc_path = dir.path().join("test.md");
946 let doc = "\
947# Scaffold Guide
948
949Here is an example of a component:
950
951```markdown
952<!-- agent:status -->
953example scaffold content
954<!-- /agent:status -->
955```
956
957<!-- agent:status -->
958real status content
959<!-- /agent:status -->
960";
961 std::fs::write(&doc_path, doc).unwrap();
962
963 let patches = vec![PatchBlock {
964 name: "status".to_string(),
965 content: "patched status\n".to_string(),
966 }];
967 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
968
969 assert!(result.contains("patched status\n"), "real component should receive the patch");
971 assert!(result.contains("example scaffold content"), "code block content should be preserved");
973 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
975 }
976
977 #[test]
978 fn unmatched_content_uses_boundary_marker() {
979 let dir = setup_project();
980 let file = dir.path().join("test.md");
981 let doc = concat!(
982 "---\nagent_doc_format: template\n---\n",
983 "<!-- agent:exchange patch=append -->\n",
984 "User prompt here.\n",
985 "<!-- agent:boundary:test-uuid-123 -->\n",
986 "<!-- /agent:exchange -->\n",
987 );
988 std::fs::write(&file, doc).unwrap();
989
990 let patches = vec![];
992 let unmatched = "### Re: Response\n\nResponse content here.\n";
993
994 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
995
996 let prompt_pos = result.find("User prompt here.").unwrap();
998 let response_pos = result.find("### Re: Response").unwrap();
999 assert!(
1000 response_pos > prompt_pos,
1001 "response should appear after the user prompt (boundary insertion)"
1002 );
1003
1004 assert!(
1006 !result.contains("test-uuid-123"),
1007 "boundary marker should be consumed after insertion"
1008 );
1009 }
1010
1011 #[test]
1012 fn explicit_patch_uses_boundary_marker() {
1013 let dir = setup_project();
1014 let file = dir.path().join("test.md");
1015 let doc = concat!(
1016 "---\nagent_doc_format: template\n---\n",
1017 "<!-- agent:exchange patch=append -->\n",
1018 "User prompt here.\n",
1019 "<!-- agent:boundary:patch-uuid-456 -->\n",
1020 "<!-- /agent:exchange -->\n",
1021 );
1022 std::fs::write(&file, doc).unwrap();
1023
1024 let patches = vec![PatchBlock {
1026 name: "exchange".to_string(),
1027 content: "### Re: Response\n\nResponse content.\n".to_string(),
1028 }];
1029
1030 let result = apply_patches(doc, &patches, "", &file).unwrap();
1031
1032 let prompt_pos = result.find("User prompt here.").unwrap();
1034 let response_pos = result.find("### Re: Response").unwrap();
1035 assert!(
1036 response_pos > prompt_pos,
1037 "response should appear after user prompt"
1038 );
1039
1040 assert!(
1042 !result.contains("patch-uuid-456"),
1043 "boundary marker should be consumed by explicit patch"
1044 );
1045 }
1046
1047 #[test]
1048 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1049 let dir = setup_project();
1052 let file = dir.path().join("test.md");
1053 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1055 std::fs::write(&file, doc).unwrap();
1056
1057 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1058 let (patches, unmatched) = parse_patches(response).unwrap();
1059 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1060
1061 assert!(
1063 result.contains("<!-- agent:boundary:"),
1064 "boundary must be re-inserted even when original doc had no boundary: {result}"
1065 );
1066 }
1067
1068 #[test]
1069 fn boundary_survives_multiple_cycles() {
1070 let dir = setup_project();
1072 let file = dir.path().join("test.md");
1073 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1074 std::fs::write(&file, doc).unwrap();
1075
1076 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1078 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1079 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1080 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1081
1082 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1084 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1085 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1086 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1087 }
1088
1089 #[test]
1090 fn remove_all_boundaries_skips_code_blocks() {
1091 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1092 let result = remove_all_boundaries(doc);
1093 assert!(
1095 result.contains("<!-- agent:boundary:fake-id -->"),
1096 "boundary inside code block must be preserved: {result}"
1097 );
1098 assert!(
1100 !result.contains("<!-- agent:boundary:real-id -->"),
1101 "boundary outside code block must be removed: {result}"
1102 );
1103 }
1104
1105 #[test]
1106 fn reposition_boundary_moves_to_end() {
1107 let doc = "\
1108<!-- agent:exchange -->
1109Previous response.
1110<!-- agent:boundary:old-id -->
1111User prompt here.
1112<!-- /agent:exchange -->";
1113 let result = reposition_boundary_to_end(doc);
1114 assert!(!result.contains("old-id"), "old boundary should be removed");
1116 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1118 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1120 let prompt_pos = result.find("User prompt here.").unwrap();
1121 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1122 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1123 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1124 }
1125
1126 #[test]
1127 fn reposition_boundary_no_exchange_unchanged() {
1128 let doc = "\
1129<!-- agent:output -->
1130Some content.
1131<!-- /agent:output -->";
1132 let result = reposition_boundary_to_end(doc);
1133 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1134 }
1135}