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 = uuid::Uuid::new_v4().to_string();
171 let marker = format!("<!-- agent:boundary:{} -->", 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[..id.len().min(8)]);
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
298fn remove_all_boundaries(doc: &str) -> String {
301 let prefix = "<!-- agent:boundary:";
302 let suffix = " -->";
303 let code_ranges = component::find_code_ranges(doc);
304 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
305 let mut result = String::with_capacity(doc.len());
306 let mut offset = 0;
307 for line in doc.lines() {
308 let trimmed = line.trim();
309 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
310 if is_boundary && !in_code(offset) {
311 offset += line.len() + 1; continue;
314 }
315 result.push_str(line);
316 result.push('\n');
317 offset += line.len() + 1;
318 }
319 if !doc.ends_with('\n') && result.ends_with('\n') {
320 result.pop();
321 }
322 result
323}
324
325fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
327 let prefix = "<!-- agent:boundary:";
328 let suffix = " -->";
329 let content_region = &doc[comp.open_end..comp.close_start];
330 let code_ranges = component::find_code_ranges(doc);
331 let mut search_from = 0;
332 while let Some(start) = content_region[search_from..].find(prefix) {
333 let abs_start = comp.open_end + search_from + start;
334 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
335 search_from += start + prefix.len();
336 continue;
337 }
338 let after_prefix = &content_region[search_from + start + prefix.len()..];
339 if let Some(end) = after_prefix.find(suffix) {
340 return Some(after_prefix[..end].trim().to_string());
341 }
342 break;
343 }
344 None
345}
346
347pub fn template_info(file: &Path) -> Result<TemplateInfo> {
349 let doc = std::fs::read_to_string(file)
350 .with_context(|| format!("failed to read {}", file.display()))?;
351
352 let (fm, _body) = crate::frontmatter::parse(&doc)?;
353 let template_mode = fm.resolve_mode().is_template();
354
355 let components = component::parse(&doc)
356 .with_context(|| format!("failed to parse components in {}", file.display()))?;
357
358 let configs = load_component_configs(file);
359
360 let component_infos: Vec<ComponentInfo> = components
361 .iter()
362 .map(|comp| {
363 let content = comp.content(&doc).to_string();
364 let mode = comp.patch_mode().map(|s| s.to_string())
366 .or_else(|| configs.get(&comp.name).cloned())
367 .unwrap_or_else(|| default_mode(&comp.name).to_string());
368 let line = doc[..comp.open_start].matches('\n').count() + 1;
370 ComponentInfo {
371 name: comp.name.clone(),
372 mode,
373 content,
374 line,
375 max_entries: None, }
377 })
378 .collect();
379
380 Ok(TemplateInfo {
381 template_mode,
382 components: component_infos,
383 })
384}
385
386fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
389 let mut result = std::collections::HashMap::new();
390 let root = find_project_root(file);
391 if let Some(root) = root {
392 let config_path = root.join(".agent-doc/components.toml");
393 if config_path.exists()
394 && let Ok(content) = std::fs::read_to_string(&config_path)
395 && let Ok(table) = content.parse::<toml::Table>()
396 {
397 for (name, value) in &table {
398 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
400 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
401 {
402 result.insert(name.clone(), mode.to_string());
403 }
404 }
405 }
406 }
407 result
408}
409
410fn default_mode(name: &str) -> &'static str {
413 match name {
414 "exchange" | "findings" => "append",
415 _ => "replace",
416 }
417}
418
419fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
421 match mode {
422 "append" => format!("{}{}", existing, new_content),
423 "prepend" => format!("{}{}", new_content, existing),
424 _ => new_content.to_string(), }
426}
427
428fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
429 let canonical = file.canonicalize().ok()?;
430 let mut dir = canonical.parent()?;
431 loop {
432 if dir.join(".agent-doc").is_dir() {
433 return Some(dir.to_path_buf());
434 }
435 dir = dir.parent()?;
436 }
437}
438
439fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
442 let mut search_start = from;
443 loop {
444 let rel = haystack[search_start..].find(needle)?;
445 let abs = search_start + rel;
446 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
447 search_start = abs + needle.len();
449 continue;
450 }
451 return Some(abs);
452 }
453}
454
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use tempfile::TempDir;
460
461 fn setup_project() -> TempDir {
462 let dir = TempDir::new().unwrap();
463 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
464 dir
465 }
466
467 #[test]
468 fn parse_single_patch() {
469 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
470 let (patches, unmatched) = parse_patches(response).unwrap();
471 assert_eq!(patches.len(), 1);
472 assert_eq!(patches[0].name, "status");
473 assert_eq!(patches[0].content, "Build passing.\n");
474 assert!(unmatched.is_empty());
475 }
476
477 #[test]
478 fn parse_multiple_patches() {
479 let response = "\
480<!-- patch:status -->
481All green.
482<!-- /patch:status -->
483
484<!-- patch:log -->
485- New entry
486<!-- /patch:log -->
487";
488 let (patches, unmatched) = parse_patches(response).unwrap();
489 assert_eq!(patches.len(), 2);
490 assert_eq!(patches[0].name, "status");
491 assert_eq!(patches[0].content, "All green.\n");
492 assert_eq!(patches[1].name, "log");
493 assert_eq!(patches[1].content, "- New entry\n");
494 assert!(unmatched.is_empty());
495 }
496
497 #[test]
498 fn parse_with_unmatched_content() {
499 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
500 let (patches, unmatched) = parse_patches(response).unwrap();
501 assert_eq!(patches.len(), 1);
502 assert_eq!(patches[0].name, "status");
503 assert!(unmatched.contains("Some free text."));
504 assert!(unmatched.contains("Trailing text."));
505 }
506
507 #[test]
508 fn parse_empty_response() {
509 let (patches, unmatched) = parse_patches("").unwrap();
510 assert!(patches.is_empty());
511 assert!(unmatched.is_empty());
512 }
513
514 #[test]
515 fn parse_no_patches() {
516 let response = "Just a plain response with no patch blocks.";
517 let (patches, unmatched) = parse_patches(response).unwrap();
518 assert!(patches.is_empty());
519 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
520 }
521
522 #[test]
523 fn apply_patches_replace() {
524 let dir = setup_project();
525 let doc_path = dir.path().join("test.md");
526 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
527 std::fs::write(&doc_path, doc).unwrap();
528
529 let patches = vec![PatchBlock {
530 name: "status".to_string(),
531 content: "new\n".to_string(),
532 }];
533 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
534 assert!(result.contains("new\n"));
535 assert!(!result.contains("\nold\n"));
536 assert!(result.contains("<!-- agent:status -->"));
537 }
538
539 #[test]
540 fn apply_patches_unmatched_creates_exchange() {
541 let dir = setup_project();
542 let doc_path = dir.path().join("test.md");
543 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
544 std::fs::write(&doc_path, doc).unwrap();
545
546 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
547 assert!(result.contains("<!-- agent:exchange -->"));
548 assert!(result.contains("Extra info here"));
549 assert!(result.contains("<!-- /agent:exchange -->"));
550 }
551
552 #[test]
553 fn apply_patches_unmatched_appends_to_existing_exchange() {
554 let dir = setup_project();
555 let doc_path = dir.path().join("test.md");
556 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
557 std::fs::write(&doc_path, doc).unwrap();
558
559 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
560 assert!(result.contains("previous"));
561 assert!(result.contains("new stuff"));
562 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
564 }
565
566 #[test]
567 fn apply_patches_missing_component_routes_to_exchange() {
568 let dir = setup_project();
569 let doc_path = dir.path().join("test.md");
570 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
571 std::fs::write(&doc_path, doc).unwrap();
572
573 let patches = vec![PatchBlock {
574 name: "nonexistent".to_string(),
575 content: "overflow data\n".to_string(),
576 }];
577 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
578 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
580 assert!(result.contains("previous"), "existing exchange content should be preserved");
581 }
582
583 #[test]
584 fn apply_patches_missing_component_creates_exchange() {
585 let dir = setup_project();
586 let doc_path = dir.path().join("test.md");
587 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
588 std::fs::write(&doc_path, doc).unwrap();
589
590 let patches = vec![PatchBlock {
591 name: "nonexistent".to_string(),
592 content: "overflow data\n".to_string(),
593 }];
594 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
595 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
597 assert!(result.contains("overflow data"), "overflow content should be in exchange");
598 }
599
600 #[test]
601 fn is_template_mode_detection() {
602 assert!(is_template_mode(Some("template")));
603 assert!(!is_template_mode(Some("append")));
604 assert!(!is_template_mode(None));
605 }
606
607 #[test]
608 fn template_info_works() {
609 let dir = setup_project();
610 let doc_path = dir.path().join("test.md");
611 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
612 std::fs::write(&doc_path, doc).unwrap();
613
614 let info = template_info(&doc_path).unwrap();
615 assert!(info.template_mode);
616 assert_eq!(info.components.len(), 1);
617 assert_eq!(info.components[0].name, "status");
618 assert_eq!(info.components[0].content, "content\n");
619 }
620
621 #[test]
622 fn template_info_legacy_mode_works() {
623 let dir = setup_project();
624 let doc_path = dir.path().join("test.md");
625 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
626 std::fs::write(&doc_path, doc).unwrap();
627
628 let info = template_info(&doc_path).unwrap();
629 assert!(info.template_mode);
630 }
631
632 #[test]
633 fn template_info_append_mode() {
634 let dir = setup_project();
635 let doc_path = dir.path().join("test.md");
636 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
637 std::fs::write(&doc_path, doc).unwrap();
638
639 let info = template_info(&doc_path).unwrap();
640 assert!(!info.template_mode);
641 assert!(info.components.is_empty());
642 }
643
644 #[test]
645 fn parse_patches_ignores_markers_in_fenced_code_block() {
646 let response = "\
647<!-- patch:exchange -->
648Here is how you use component markers:
649
650```markdown
651<!-- agent:exchange -->
652example content
653<!-- /agent:exchange -->
654```
655
656<!-- /patch:exchange -->
657";
658 let (patches, unmatched) = parse_patches(response).unwrap();
659 assert_eq!(patches.len(), 1);
660 assert_eq!(patches[0].name, "exchange");
661 assert!(patches[0].content.contains("```markdown"));
662 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
663 assert!(unmatched.is_empty());
664 }
665
666 #[test]
667 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
668 let response = "\
670<!-- patch:exchange -->
671Real content here.
672
673```markdown
674<!-- patch:fake -->
675This is just an example.
676<!-- /patch:fake -->
677```
678
679<!-- /patch:exchange -->
680";
681 let (patches, unmatched) = parse_patches(response).unwrap();
682 assert_eq!(patches.len(), 1, "should only find the outer real patch");
683 assert_eq!(patches[0].name, "exchange");
684 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
685 assert!(unmatched.is_empty());
686 }
687
688 #[test]
689 fn parse_patches_ignores_markers_in_tilde_fence() {
690 let response = "\
691<!-- patch:status -->
692OK
693<!-- /patch:status -->
694
695~~~
696<!-- patch:fake -->
697example
698<!-- /patch:fake -->
699~~~
700";
701 let (patches, _unmatched) = parse_patches(response).unwrap();
702 assert_eq!(patches.len(), 1);
704 assert_eq!(patches[0].name, "status");
705 }
706
707 #[test]
708 fn parse_patches_ignores_closing_marker_in_code_block() {
709 let response = "\
712<!-- patch:exchange -->
713Example:
714
715```
716<!-- /patch:exchange -->
717```
718
719Real content continues.
720<!-- /patch:exchange -->
721";
722 let (patches, unmatched) = parse_patches(response).unwrap();
723 assert_eq!(patches.len(), 1);
724 assert_eq!(patches[0].name, "exchange");
725 assert!(patches[0].content.contains("Real content continues."));
726 }
727
728 #[test]
729 fn parse_patches_normal_markers_still_work() {
730 let response = "\
732<!-- patch:status -->
733All systems go.
734<!-- /patch:status -->
735<!-- patch:log -->
736- Entry 1
737<!-- /patch:log -->
738";
739 let (patches, unmatched) = parse_patches(response).unwrap();
740 assert_eq!(patches.len(), 2);
741 assert_eq!(patches[0].name, "status");
742 assert_eq!(patches[0].content, "All systems go.\n");
743 assert_eq!(patches[1].name, "log");
744 assert_eq!(patches[1].content, "- Entry 1\n");
745 assert!(unmatched.is_empty());
746 }
747
748 #[test]
751 fn inline_attr_mode_overrides_config() {
752 let dir = setup_project();
754 let doc_path = dir.path().join("test.md");
755 std::fs::write(
757 dir.path().join(".agent-doc/components.toml"),
758 "[status]\nmode = \"append\"\n",
759 ).unwrap();
760 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
762 std::fs::write(&doc_path, doc).unwrap();
763
764 let patches = vec![PatchBlock {
765 name: "status".to_string(),
766 content: "new\n".to_string(),
767 }];
768 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
769 assert!(result.contains("new\n"));
771 assert!(!result.contains("old\n"));
772 }
773
774 #[test]
775 fn inline_attr_mode_overrides_default() {
776 let dir = setup_project();
778 let doc_path = dir.path().join("test.md");
779 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
780 std::fs::write(&doc_path, doc).unwrap();
781
782 let patches = vec![PatchBlock {
783 name: "exchange".to_string(),
784 content: "new\n".to_string(),
785 }];
786 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
787 assert!(result.contains("new\n"));
788 assert!(!result.contains("old\n"));
789 }
790
791 #[test]
792 fn no_inline_attr_falls_back_to_config() {
793 let dir = setup_project();
795 let doc_path = dir.path().join("test.md");
796 std::fs::write(
797 dir.path().join(".agent-doc/components.toml"),
798 "[status]\nmode = \"append\"\n",
799 ).unwrap();
800 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
801 std::fs::write(&doc_path, doc).unwrap();
802
803 let patches = vec![PatchBlock {
804 name: "status".to_string(),
805 content: "new\n".to_string(),
806 }];
807 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
808 assert!(result.contains("old\n"));
810 assert!(result.contains("new\n"));
811 }
812
813 #[test]
814 fn no_inline_attr_no_config_falls_back_to_default() {
815 let dir = setup_project();
817 let doc_path = dir.path().join("test.md");
818 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
819 std::fs::write(&doc_path, doc).unwrap();
820
821 let patches = vec![PatchBlock {
822 name: "exchange".to_string(),
823 content: "new\n".to_string(),
824 }];
825 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
826 assert!(result.contains("old\n"));
828 assert!(result.contains("new\n"));
829 }
830
831 #[test]
832 fn inline_patch_attr_overrides_config() {
833 let dir = setup_project();
835 let doc_path = dir.path().join("test.md");
836 std::fs::write(
837 dir.path().join(".agent-doc/components.toml"),
838 "[status]\nmode = \"append\"\n",
839 ).unwrap();
840 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
841 std::fs::write(&doc_path, doc).unwrap();
842
843 let patches = vec![PatchBlock {
844 name: "status".to_string(),
845 content: "new\n".to_string(),
846 }];
847 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
848 assert!(result.contains("new\n"));
849 assert!(!result.contains("old\n"));
850 }
851
852 #[test]
853 fn inline_patch_attr_overrides_mode_attr() {
854 let dir = setup_project();
856 let doc_path = dir.path().join("test.md");
857 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
858 std::fs::write(&doc_path, doc).unwrap();
859
860 let patches = vec![PatchBlock {
861 name: "exchange".to_string(),
862 content: "new\n".to_string(),
863 }];
864 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
865 assert!(result.contains("new\n"));
866 assert!(!result.contains("old\n"));
867 }
868
869 #[test]
870 fn toml_patch_key_works() {
871 let dir = setup_project();
873 let doc_path = dir.path().join("test.md");
874 std::fs::write(
875 dir.path().join(".agent-doc/components.toml"),
876 "[status]\npatch = \"append\"\n",
877 ).unwrap();
878 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
879 std::fs::write(&doc_path, doc).unwrap();
880
881 let patches = vec![PatchBlock {
882 name: "status".to_string(),
883 content: "new\n".to_string(),
884 }];
885 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
886 assert!(result.contains("old\n"));
887 assert!(result.contains("new\n"));
888 }
889
890 #[test]
891 fn stream_override_beats_inline_attr() {
892 let dir = setup_project();
894 let doc_path = dir.path().join("test.md");
895 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
896 std::fs::write(&doc_path, doc).unwrap();
897
898 let patches = vec![PatchBlock {
899 name: "exchange".to_string(),
900 content: "new\n".to_string(),
901 }];
902 let mut overrides = std::collections::HashMap::new();
903 overrides.insert("exchange".to_string(), "replace".to_string());
904 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
905 assert!(result.contains("new\n"));
907 assert!(!result.contains("old\n"));
908 }
909
910 #[test]
911 fn apply_patches_ignores_component_tags_in_code_blocks() {
912 let dir = setup_project();
915 let doc_path = dir.path().join("test.md");
916 let doc = "\
917# Scaffold Guide
918
919Here is an example of a component:
920
921```markdown
922<!-- agent:status -->
923example scaffold content
924<!-- /agent:status -->
925```
926
927<!-- agent:status -->
928real status content
929<!-- /agent:status -->
930";
931 std::fs::write(&doc_path, doc).unwrap();
932
933 let patches = vec![PatchBlock {
934 name: "status".to_string(),
935 content: "patched status\n".to_string(),
936 }];
937 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
938
939 assert!(result.contains("patched status\n"), "real component should receive the patch");
941 assert!(result.contains("example scaffold content"), "code block content should be preserved");
943 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
945 }
946
947 #[test]
948 fn unmatched_content_uses_boundary_marker() {
949 let dir = setup_project();
950 let file = dir.path().join("test.md");
951 let doc = concat!(
952 "---\nagent_doc_format: template\n---\n",
953 "<!-- agent:exchange patch=append -->\n",
954 "User prompt here.\n",
955 "<!-- agent:boundary:test-uuid-123 -->\n",
956 "<!-- /agent:exchange -->\n",
957 );
958 std::fs::write(&file, doc).unwrap();
959
960 let patches = vec![];
962 let unmatched = "### Re: Response\n\nResponse content here.\n";
963
964 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
965
966 let prompt_pos = result.find("User prompt here.").unwrap();
968 let response_pos = result.find("### Re: Response").unwrap();
969 assert!(
970 response_pos > prompt_pos,
971 "response should appear after the user prompt (boundary insertion)"
972 );
973
974 assert!(
976 !result.contains("test-uuid-123"),
977 "boundary marker should be consumed after insertion"
978 );
979 }
980
981 #[test]
982 fn explicit_patch_uses_boundary_marker() {
983 let dir = setup_project();
984 let file = dir.path().join("test.md");
985 let doc = concat!(
986 "---\nagent_doc_format: template\n---\n",
987 "<!-- agent:exchange patch=append -->\n",
988 "User prompt here.\n",
989 "<!-- agent:boundary:patch-uuid-456 -->\n",
990 "<!-- /agent:exchange -->\n",
991 );
992 std::fs::write(&file, doc).unwrap();
993
994 let patches = vec![PatchBlock {
996 name: "exchange".to_string(),
997 content: "### Re: Response\n\nResponse content.\n".to_string(),
998 }];
999
1000 let result = apply_patches(doc, &patches, "", &file).unwrap();
1001
1002 let prompt_pos = result.find("User prompt here.").unwrap();
1004 let response_pos = result.find("### Re: Response").unwrap();
1005 assert!(
1006 response_pos > prompt_pos,
1007 "response should appear after user prompt"
1008 );
1009
1010 assert!(
1012 !result.contains("patch-uuid-456"),
1013 "boundary marker should be consumed by explicit patch"
1014 );
1015 }
1016
1017 #[test]
1018 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1019 let dir = setup_project();
1022 let file = dir.path().join("test.md");
1023 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1025 std::fs::write(&file, doc).unwrap();
1026
1027 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1028 let (patches, unmatched) = parse_patches(response).unwrap();
1029 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1030
1031 assert!(
1033 result.contains("<!-- agent:boundary:"),
1034 "boundary must be re-inserted even when original doc had no boundary: {result}"
1035 );
1036 }
1037
1038 #[test]
1039 fn boundary_survives_multiple_cycles() {
1040 let dir = setup_project();
1042 let file = dir.path().join("test.md");
1043 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1044 std::fs::write(&file, doc).unwrap();
1045
1046 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1048 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1049 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1050 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1051
1052 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1054 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1055 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1056 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1057 }
1058
1059 #[test]
1060 fn remove_all_boundaries_skips_code_blocks() {
1061 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1062 let result = remove_all_boundaries(doc);
1063 assert!(
1065 result.contains("<!-- agent:boundary:fake-id -->"),
1066 "boundary inside code block must be preserved: {result}"
1067 );
1068 assert!(
1070 !result.contains("<!-- agent:boundary:real-id -->"),
1071 "boundary outside code block must be removed: {result}"
1072 );
1073 }
1074}