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