1use anyhow::{Context, Result};
7use serde::Serialize;
8use std::path::Path;
9
10use crate::component::{self, Component};
11
12#[derive(Debug, Clone)]
14pub struct PatchBlock {
15 pub name: String,
16 pub content: String,
17}
18
19#[derive(Debug, Serialize)]
21pub struct TemplateInfo {
22 pub template_mode: bool,
23 pub components: Vec<ComponentInfo>,
24}
25
26#[derive(Debug, Serialize)]
28pub struct ComponentInfo {
29 pub name: String,
30 pub mode: String,
31 pub content: String,
32 pub line: usize,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub max_entries: Option<usize>,
35}
36
37#[cfg(test)]
39pub fn is_template_mode(mode: Option<&str>) -> bool {
40 matches!(mode, Some("template"))
41}
42
43pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
48 let bytes = response.as_bytes();
49 let len = bytes.len();
50 let code_ranges = component::find_code_ranges(response);
51 let mut patches = Vec::new();
52 let mut unmatched = String::new();
53 let mut pos = 0;
54 let mut last_end = 0;
55
56 while pos + 4 <= len {
57 if &bytes[pos..pos + 4] != b"<!--" {
58 pos += 1;
59 continue;
60 }
61
62 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
64 pos += 4;
65 continue;
66 }
67
68 let marker_start = pos;
69
70 let close = match find_comment_end(bytes, pos + 4) {
72 Some(c) => c,
73 None => {
74 pos += 4;
75 continue;
76 }
77 };
78
79 let inner = &response[marker_start + 4..close - 3];
80 let trimmed = inner.trim();
81
82 if let Some(name) = trimmed.strip_prefix("patch:") {
83 let name = name.trim();
84 if name.is_empty() || name.starts_with('/') {
85 pos = close;
86 continue;
87 }
88
89 let mut content_start = close;
91 if content_start < len && bytes[content_start] == b'\n' {
92 content_start += 1;
93 }
94
95 let before = &response[last_end..marker_start];
97 let trimmed_before = before.trim();
98 if !trimmed_before.is_empty() {
99 if !unmatched.is_empty() {
100 unmatched.push('\n');
101 }
102 unmatched.push_str(trimmed_before);
103 }
104
105 let close_marker = format!("<!-- /patch:{} -->", name);
107 if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
108 let content = &response[content_start..close_pos];
109 patches.push(PatchBlock {
110 name: name.to_string(),
111 content: content.to_string(),
112 });
113
114 let mut end = close_pos + close_marker.len();
115 if end < len && bytes[end] == b'\n' {
116 end += 1;
117 }
118 last_end = end;
119 pos = end;
120 continue;
121 }
122 }
123
124 pos = close;
125 }
126
127 if last_end < len {
129 let trailing = response[last_end..].trim();
130 if !trailing.is_empty() {
131 if !unmatched.is_empty() {
132 unmatched.push('\n');
133 }
134 unmatched.push_str(trailing);
135 }
136 }
137
138 Ok((patches, unmatched))
139}
140
141pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
150 apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
151}
152
153pub fn apply_patches_with_overrides(
156 doc: &str,
157 patches: &[PatchBlock],
158 unmatched: &str,
159 file: &Path,
160 mode_overrides: &std::collections::HashMap<String, String>,
161) -> Result<String> {
162 let mut result = doc.to_string();
163
164 let components = component::parse(&result)
166 .context("failed to parse components")?;
167
168 let configs = load_component_configs(file);
170
171 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
176 let mut overflow = String::new();
177 for patch in patches {
178 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
179 ops.push((idx, patch));
180 } else {
181 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
182 eprintln!(
183 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
184 patch.name,
185 available.join(", ")
186 );
187 if !overflow.is_empty() {
188 overflow.push('\n');
189 }
190 overflow.push_str(&patch.content);
191 }
192 }
193
194 ops.sort_by(|a, b| b.0.cmp(&a.0));
196
197 for (idx, patch) in &ops {
198 let comp = &components[*idx];
199 let mode = mode_overrides.get(&patch.name)
201 .map(|s| s.as_str())
202 .or_else(|| comp.patch_mode())
203 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
204 .unwrap_or_else(|| default_mode(&patch.name));
205 if mode == "append"
207 && let Some(bid) = find_boundary_in_component(&result, comp)
208 {
209 result = comp.append_with_boundary(&result, &patch.content, &bid);
210 continue;
211 }
212 let new_content = apply_mode(mode, comp.content(&result), &patch.content);
213 result = comp.replace_content(&result, &new_content);
214 }
215
216 let mut all_unmatched = String::new();
218 if !overflow.is_empty() {
219 all_unmatched.push_str(&overflow);
220 }
221 if !unmatched.is_empty() {
222 if !all_unmatched.is_empty() {
223 all_unmatched.push('\n');
224 }
225 all_unmatched.push_str(unmatched);
226 }
227
228 if !all_unmatched.is_empty() {
230 let unmatched = &all_unmatched;
231 let components = component::parse(&result)
233 .context("failed to re-parse components after patching")?;
234
235 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
236 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
238 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
239 result = output_comp.append_with_boundary(&result, unmatched, &bid);
240 } else {
241 let existing = output_comp.content(&result);
243 let new_content = if existing.trim().is_empty() {
244 format!("{}\n", unmatched)
245 } else {
246 format!("{}{}\n", existing, unmatched)
247 };
248 result = output_comp.replace_content(&result, &new_content);
249 }
250 } else {
251 if !result.ends_with('\n') {
253 result.push('\n');
254 }
255 result.push_str("\n<!-- agent:exchange -->\n");
256 result.push_str(unmatched);
257 result.push_str("\n<!-- /agent:exchange -->\n");
258 }
259 }
260
261 {
265 let orig_had_boundary = component::parse(doc).ok().and_then(|cs| {
267 cs.iter()
268 .find(|c| c.name == "exchange")
269 .and_then(|c| find_boundary_in_component(doc, c))
270 });
271 if orig_had_boundary.is_some()
272 && let Ok(components) = component::parse(&result)
273 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
274 && find_boundary_in_component(&result, exchange).is_none()
275 {
276 let id = uuid::Uuid::new_v4().to_string();
278 let marker = format!("<!-- agent:boundary:{} -->", id);
279 let content = exchange.content(&result);
280 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
281 result = exchange.replace_content(&result, &new_content);
282 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
283 }
284 }
285
286 Ok(result)
287}
288
289fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
291 let prefix = "<!-- agent:boundary:";
292 let suffix = " -->";
293 let content_region = &doc[comp.open_end..comp.close_start];
294 let code_ranges = component::find_code_ranges(doc);
295 let mut search_from = 0;
296 while let Some(start) = content_region[search_from..].find(prefix) {
297 let abs_start = comp.open_end + search_from + start;
298 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
299 search_from += start + prefix.len();
300 continue;
301 }
302 let after_prefix = &content_region[search_from + start + prefix.len()..];
303 if let Some(end) = after_prefix.find(suffix) {
304 return Some(after_prefix[..end].trim().to_string());
305 }
306 break;
307 }
308 None
309}
310
311pub fn template_info(file: &Path) -> Result<TemplateInfo> {
313 let doc = std::fs::read_to_string(file)
314 .with_context(|| format!("failed to read {}", file.display()))?;
315
316 let (fm, _body) = crate::frontmatter::parse(&doc)?;
317 let template_mode = fm.resolve_mode().is_template();
318
319 let components = component::parse(&doc)
320 .with_context(|| format!("failed to parse components in {}", file.display()))?;
321
322 let configs = load_component_configs(file);
323
324 let component_infos: Vec<ComponentInfo> = components
325 .iter()
326 .map(|comp| {
327 let content = comp.content(&doc).to_string();
328 let mode = comp.patch_mode().map(|s| s.to_string())
330 .or_else(|| configs.get(&comp.name).cloned())
331 .unwrap_or_else(|| default_mode(&comp.name).to_string());
332 let line = doc[..comp.open_start].matches('\n').count() + 1;
334 ComponentInfo {
335 name: comp.name.clone(),
336 mode,
337 content,
338 line,
339 max_entries: None, }
341 })
342 .collect();
343
344 Ok(TemplateInfo {
345 template_mode,
346 components: component_infos,
347 })
348}
349
350fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
353 let mut result = std::collections::HashMap::new();
354 let root = find_project_root(file);
355 if let Some(root) = root {
356 let config_path = root.join(".agent-doc/components.toml");
357 if config_path.exists()
358 && let Ok(content) = std::fs::read_to_string(&config_path)
359 && let Ok(table) = content.parse::<toml::Table>()
360 {
361 for (name, value) in &table {
362 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
364 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
365 {
366 result.insert(name.clone(), mode.to_string());
367 }
368 }
369 }
370 }
371 result
372}
373
374fn default_mode(name: &str) -> &'static str {
377 match name {
378 "exchange" | "findings" => "append",
379 _ => "replace",
380 }
381}
382
383fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
385 match mode {
386 "append" => format!("{}{}", existing, new_content),
387 "prepend" => format!("{}{}", new_content, existing),
388 _ => new_content.to_string(), }
390}
391
392fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
393 let canonical = file.canonicalize().ok()?;
394 let mut dir = canonical.parent()?;
395 loop {
396 if dir.join(".agent-doc").is_dir() {
397 return Some(dir.to_path_buf());
398 }
399 dir = dir.parent()?;
400 }
401}
402
403fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
406 let mut search_start = from;
407 loop {
408 let rel = haystack[search_start..].find(needle)?;
409 let abs = search_start + rel;
410 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
411 search_start = abs + needle.len();
413 continue;
414 }
415 return Some(abs);
416 }
417}
418
419fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
420 let len = bytes.len();
421 let mut i = start;
422 while i + 3 <= len {
423 if &bytes[i..i + 3] == b"-->" {
424 return Some(i + 3);
425 }
426 i += 1;
427 }
428 None
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use tempfile::TempDir;
435
436 fn setup_project() -> TempDir {
437 let dir = TempDir::new().unwrap();
438 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
439 dir
440 }
441
442 #[test]
443 fn parse_single_patch() {
444 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
445 let (patches, unmatched) = parse_patches(response).unwrap();
446 assert_eq!(patches.len(), 1);
447 assert_eq!(patches[0].name, "status");
448 assert_eq!(patches[0].content, "Build passing.\n");
449 assert!(unmatched.is_empty());
450 }
451
452 #[test]
453 fn parse_multiple_patches() {
454 let response = "\
455<!-- patch:status -->
456All green.
457<!-- /patch:status -->
458
459<!-- patch:log -->
460- New entry
461<!-- /patch:log -->
462";
463 let (patches, unmatched) = parse_patches(response).unwrap();
464 assert_eq!(patches.len(), 2);
465 assert_eq!(patches[0].name, "status");
466 assert_eq!(patches[0].content, "All green.\n");
467 assert_eq!(patches[1].name, "log");
468 assert_eq!(patches[1].content, "- New entry\n");
469 assert!(unmatched.is_empty());
470 }
471
472 #[test]
473 fn parse_with_unmatched_content() {
474 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
475 let (patches, unmatched) = parse_patches(response).unwrap();
476 assert_eq!(patches.len(), 1);
477 assert_eq!(patches[0].name, "status");
478 assert!(unmatched.contains("Some free text."));
479 assert!(unmatched.contains("Trailing text."));
480 }
481
482 #[test]
483 fn parse_empty_response() {
484 let (patches, unmatched) = parse_patches("").unwrap();
485 assert!(patches.is_empty());
486 assert!(unmatched.is_empty());
487 }
488
489 #[test]
490 fn parse_no_patches() {
491 let response = "Just a plain response with no patch blocks.";
492 let (patches, unmatched) = parse_patches(response).unwrap();
493 assert!(patches.is_empty());
494 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
495 }
496
497 #[test]
498 fn apply_patches_replace() {
499 let dir = setup_project();
500 let doc_path = dir.path().join("test.md");
501 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
502 std::fs::write(&doc_path, doc).unwrap();
503
504 let patches = vec![PatchBlock {
505 name: "status".to_string(),
506 content: "new\n".to_string(),
507 }];
508 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
509 assert!(result.contains("new\n"));
510 assert!(!result.contains("\nold\n"));
511 assert!(result.contains("<!-- agent:status -->"));
512 }
513
514 #[test]
515 fn apply_patches_unmatched_creates_exchange() {
516 let dir = setup_project();
517 let doc_path = dir.path().join("test.md");
518 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
519 std::fs::write(&doc_path, doc).unwrap();
520
521 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
522 assert!(result.contains("<!-- agent:exchange -->"));
523 assert!(result.contains("Extra info here"));
524 assert!(result.contains("<!-- /agent:exchange -->"));
525 }
526
527 #[test]
528 fn apply_patches_unmatched_appends_to_existing_exchange() {
529 let dir = setup_project();
530 let doc_path = dir.path().join("test.md");
531 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
532 std::fs::write(&doc_path, doc).unwrap();
533
534 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
535 assert!(result.contains("previous"));
536 assert!(result.contains("new stuff"));
537 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
539 }
540
541 #[test]
542 fn apply_patches_missing_component_routes_to_exchange() {
543 let dir = setup_project();
544 let doc_path = dir.path().join("test.md");
545 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
546 std::fs::write(&doc_path, doc).unwrap();
547
548 let patches = vec![PatchBlock {
549 name: "nonexistent".to_string(),
550 content: "overflow data\n".to_string(),
551 }];
552 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
553 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
555 assert!(result.contains("previous"), "existing exchange content should be preserved");
556 }
557
558 #[test]
559 fn apply_patches_missing_component_creates_exchange() {
560 let dir = setup_project();
561 let doc_path = dir.path().join("test.md");
562 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
563 std::fs::write(&doc_path, doc).unwrap();
564
565 let patches = vec![PatchBlock {
566 name: "nonexistent".to_string(),
567 content: "overflow data\n".to_string(),
568 }];
569 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
570 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
572 assert!(result.contains("overflow data"), "overflow content should be in exchange");
573 }
574
575 #[test]
576 fn is_template_mode_detection() {
577 assert!(is_template_mode(Some("template")));
578 assert!(!is_template_mode(Some("append")));
579 assert!(!is_template_mode(None));
580 }
581
582 #[test]
583 fn template_info_works() {
584 let dir = setup_project();
585 let doc_path = dir.path().join("test.md");
586 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
587 std::fs::write(&doc_path, doc).unwrap();
588
589 let info = template_info(&doc_path).unwrap();
590 assert!(info.template_mode);
591 assert_eq!(info.components.len(), 1);
592 assert_eq!(info.components[0].name, "status");
593 assert_eq!(info.components[0].content, "content\n");
594 }
595
596 #[test]
597 fn template_info_legacy_mode_works() {
598 let dir = setup_project();
599 let doc_path = dir.path().join("test.md");
600 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
601 std::fs::write(&doc_path, doc).unwrap();
602
603 let info = template_info(&doc_path).unwrap();
604 assert!(info.template_mode);
605 }
606
607 #[test]
608 fn template_info_append_mode() {
609 let dir = setup_project();
610 let doc_path = dir.path().join("test.md");
611 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\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!(info.components.is_empty());
617 }
618
619 #[test]
620 fn parse_patches_ignores_markers_in_fenced_code_block() {
621 let response = "\
622<!-- patch:exchange -->
623Here is how you use component markers:
624
625```markdown
626<!-- agent:exchange -->
627example content
628<!-- /agent:exchange -->
629```
630
631<!-- /patch:exchange -->
632";
633 let (patches, unmatched) = parse_patches(response).unwrap();
634 assert_eq!(patches.len(), 1);
635 assert_eq!(patches[0].name, "exchange");
636 assert!(patches[0].content.contains("```markdown"));
637 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
638 assert!(unmatched.is_empty());
639 }
640
641 #[test]
642 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
643 let response = "\
645<!-- patch:exchange -->
646Real content here.
647
648```markdown
649<!-- patch:fake -->
650This is just an example.
651<!-- /patch:fake -->
652```
653
654<!-- /patch:exchange -->
655";
656 let (patches, unmatched) = parse_patches(response).unwrap();
657 assert_eq!(patches.len(), 1, "should only find the outer real patch");
658 assert_eq!(patches[0].name, "exchange");
659 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
660 assert!(unmatched.is_empty());
661 }
662
663 #[test]
664 fn parse_patches_ignores_markers_in_tilde_fence() {
665 let response = "\
666<!-- patch:status -->
667OK
668<!-- /patch:status -->
669
670~~~
671<!-- patch:fake -->
672example
673<!-- /patch:fake -->
674~~~
675";
676 let (patches, _unmatched) = parse_patches(response).unwrap();
677 assert_eq!(patches.len(), 1);
679 assert_eq!(patches[0].name, "status");
680 }
681
682 #[test]
683 fn parse_patches_ignores_closing_marker_in_code_block() {
684 let response = "\
687<!-- patch:exchange -->
688Example:
689
690```
691<!-- /patch:exchange -->
692```
693
694Real content continues.
695<!-- /patch:exchange -->
696";
697 let (patches, unmatched) = parse_patches(response).unwrap();
698 assert_eq!(patches.len(), 1);
699 assert_eq!(patches[0].name, "exchange");
700 assert!(patches[0].content.contains("Real content continues."));
701 }
702
703 #[test]
704 fn parse_patches_normal_markers_still_work() {
705 let response = "\
707<!-- patch:status -->
708All systems go.
709<!-- /patch:status -->
710<!-- patch:log -->
711- Entry 1
712<!-- /patch:log -->
713";
714 let (patches, unmatched) = parse_patches(response).unwrap();
715 assert_eq!(patches.len(), 2);
716 assert_eq!(patches[0].name, "status");
717 assert_eq!(patches[0].content, "All systems go.\n");
718 assert_eq!(patches[1].name, "log");
719 assert_eq!(patches[1].content, "- Entry 1\n");
720 assert!(unmatched.is_empty());
721 }
722
723 #[test]
726 fn inline_attr_mode_overrides_config() {
727 let dir = setup_project();
729 let doc_path = dir.path().join("test.md");
730 std::fs::write(
732 dir.path().join(".agent-doc/components.toml"),
733 "[status]\nmode = \"append\"\n",
734 ).unwrap();
735 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
737 std::fs::write(&doc_path, doc).unwrap();
738
739 let patches = vec![PatchBlock {
740 name: "status".to_string(),
741 content: "new\n".to_string(),
742 }];
743 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
744 assert!(result.contains("new\n"));
746 assert!(!result.contains("old\n"));
747 }
748
749 #[test]
750 fn inline_attr_mode_overrides_default() {
751 let dir = setup_project();
753 let doc_path = dir.path().join("test.md");
754 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
755 std::fs::write(&doc_path, doc).unwrap();
756
757 let patches = vec![PatchBlock {
758 name: "exchange".to_string(),
759 content: "new\n".to_string(),
760 }];
761 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
762 assert!(result.contains("new\n"));
763 assert!(!result.contains("old\n"));
764 }
765
766 #[test]
767 fn no_inline_attr_falls_back_to_config() {
768 let dir = setup_project();
770 let doc_path = dir.path().join("test.md");
771 std::fs::write(
772 dir.path().join(".agent-doc/components.toml"),
773 "[status]\nmode = \"append\"\n",
774 ).unwrap();
775 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
776 std::fs::write(&doc_path, doc).unwrap();
777
778 let patches = vec![PatchBlock {
779 name: "status".to_string(),
780 content: "new\n".to_string(),
781 }];
782 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
783 assert!(result.contains("old\n"));
785 assert!(result.contains("new\n"));
786 }
787
788 #[test]
789 fn no_inline_attr_no_config_falls_back_to_default() {
790 let dir = setup_project();
792 let doc_path = dir.path().join("test.md");
793 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
794 std::fs::write(&doc_path, doc).unwrap();
795
796 let patches = vec![PatchBlock {
797 name: "exchange".to_string(),
798 content: "new\n".to_string(),
799 }];
800 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
801 assert!(result.contains("old\n"));
803 assert!(result.contains("new\n"));
804 }
805
806 #[test]
807 fn inline_patch_attr_overrides_config() {
808 let dir = setup_project();
810 let doc_path = dir.path().join("test.md");
811 std::fs::write(
812 dir.path().join(".agent-doc/components.toml"),
813 "[status]\nmode = \"append\"\n",
814 ).unwrap();
815 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
816 std::fs::write(&doc_path, doc).unwrap();
817
818 let patches = vec![PatchBlock {
819 name: "status".to_string(),
820 content: "new\n".to_string(),
821 }];
822 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
823 assert!(result.contains("new\n"));
824 assert!(!result.contains("old\n"));
825 }
826
827 #[test]
828 fn inline_patch_attr_overrides_mode_attr() {
829 let dir = setup_project();
831 let doc_path = dir.path().join("test.md");
832 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
833 std::fs::write(&doc_path, doc).unwrap();
834
835 let patches = vec![PatchBlock {
836 name: "exchange".to_string(),
837 content: "new\n".to_string(),
838 }];
839 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
840 assert!(result.contains("new\n"));
841 assert!(!result.contains("old\n"));
842 }
843
844 #[test]
845 fn toml_patch_key_works() {
846 let dir = setup_project();
848 let doc_path = dir.path().join("test.md");
849 std::fs::write(
850 dir.path().join(".agent-doc/components.toml"),
851 "[status]\npatch = \"append\"\n",
852 ).unwrap();
853 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
854 std::fs::write(&doc_path, doc).unwrap();
855
856 let patches = vec![PatchBlock {
857 name: "status".to_string(),
858 content: "new\n".to_string(),
859 }];
860 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
861 assert!(result.contains("old\n"));
862 assert!(result.contains("new\n"));
863 }
864
865 #[test]
866 fn stream_override_beats_inline_attr() {
867 let dir = setup_project();
869 let doc_path = dir.path().join("test.md");
870 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
871 std::fs::write(&doc_path, doc).unwrap();
872
873 let patches = vec![PatchBlock {
874 name: "exchange".to_string(),
875 content: "new\n".to_string(),
876 }];
877 let mut overrides = std::collections::HashMap::new();
878 overrides.insert("exchange".to_string(), "replace".to_string());
879 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
880 assert!(result.contains("new\n"));
882 assert!(!result.contains("old\n"));
883 }
884
885 #[test]
886 fn apply_patches_ignores_component_tags_in_code_blocks() {
887 let dir = setup_project();
890 let doc_path = dir.path().join("test.md");
891 let doc = "\
892# Scaffold Guide
893
894Here is an example of a component:
895
896```markdown
897<!-- agent:status -->
898example scaffold content
899<!-- /agent:status -->
900```
901
902<!-- agent:status -->
903real status content
904<!-- /agent:status -->
905";
906 std::fs::write(&doc_path, doc).unwrap();
907
908 let patches = vec![PatchBlock {
909 name: "status".to_string(),
910 content: "patched status\n".to_string(),
911 }];
912 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
913
914 assert!(result.contains("patched status\n"), "real component should receive the patch");
916 assert!(result.contains("example scaffold content"), "code block content should be preserved");
918 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
920 }
921
922 #[test]
923 fn unmatched_content_uses_boundary_marker() {
924 let dir = setup_project();
925 let file = dir.path().join("test.md");
926 let doc = concat!(
927 "---\nagent_doc_format: template\n---\n",
928 "<!-- agent:exchange patch=append -->\n",
929 "User prompt here.\n",
930 "<!-- agent:boundary:test-uuid-123 -->\n",
931 "<!-- /agent:exchange -->\n",
932 );
933 std::fs::write(&file, doc).unwrap();
934
935 let patches = vec![];
937 let unmatched = "### Re: Response\n\nResponse content here.\n";
938
939 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
940
941 let prompt_pos = result.find("User prompt here.").unwrap();
943 let response_pos = result.find("### Re: Response").unwrap();
944 assert!(
945 response_pos > prompt_pos,
946 "response should appear after the user prompt (boundary insertion)"
947 );
948
949 assert!(
951 !result.contains("test-uuid-123"),
952 "boundary marker should be consumed after insertion"
953 );
954 }
955
956 #[test]
957 fn explicit_patch_uses_boundary_marker() {
958 let dir = setup_project();
959 let file = dir.path().join("test.md");
960 let doc = concat!(
961 "---\nagent_doc_format: template\n---\n",
962 "<!-- agent:exchange patch=append -->\n",
963 "User prompt here.\n",
964 "<!-- agent:boundary:patch-uuid-456 -->\n",
965 "<!-- /agent:exchange -->\n",
966 );
967 std::fs::write(&file, doc).unwrap();
968
969 let patches = vec![PatchBlock {
971 name: "exchange".to_string(),
972 content: "### Re: Response\n\nResponse content.\n".to_string(),
973 }];
974
975 let result = apply_patches(doc, &patches, "", &file).unwrap();
976
977 let prompt_pos = result.find("User prompt here.").unwrap();
979 let response_pos = result.find("### Re: Response").unwrap();
980 assert!(
981 response_pos > prompt_pos,
982 "response should appear after user prompt"
983 );
984
985 assert!(
987 !result.contains("patch-uuid-456"),
988 "boundary marker should be consumed by explicit patch"
989 );
990 }
991}