1use anyhow::{Context, Result};
80use serde::Serialize;
81use std::path::Path;
82
83use crate::component::{self, find_comment_end, Component};
84
85#[derive(Debug, Clone)]
87pub struct PatchBlock {
88 pub name: String,
89 pub content: String,
90}
91
92#[derive(Debug, Serialize)]
94pub struct TemplateInfo {
95 pub template_mode: bool,
96 pub components: Vec<ComponentInfo>,
97}
98
99#[derive(Debug, Serialize)]
101pub struct ComponentInfo {
102 pub name: String,
103 pub mode: String,
104 pub content: String,
105 pub line: usize,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub max_entries: Option<usize>,
108}
109
110#[cfg(test)]
112pub fn is_template_mode(mode: Option<&str>) -> bool {
113 matches!(mode, Some("template"))
114}
115
116pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
121 let bytes = response.as_bytes();
122 let len = bytes.len();
123 let code_ranges = component::find_code_ranges(response);
124 let mut patches = Vec::new();
125 let mut unmatched = String::new();
126 let mut pos = 0;
127 let mut last_end = 0;
128
129 while pos + 4 <= len {
130 if &bytes[pos..pos + 4] != b"<!--" {
131 pos += 1;
132 continue;
133 }
134
135 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
137 pos += 4;
138 continue;
139 }
140
141 let marker_start = pos;
142
143 let close = match find_comment_end(bytes, pos + 4) {
145 Some(c) => c,
146 None => {
147 pos += 4;
148 continue;
149 }
150 };
151
152 let inner = &response[marker_start + 4..close - 3];
153 let trimmed = inner.trim();
154
155 if let Some(name) = trimmed.strip_prefix("patch:") {
156 let name = name.trim();
157 if name.is_empty() || name.starts_with('/') {
158 pos = close;
159 continue;
160 }
161
162 let mut content_start = close;
164 if content_start < len && bytes[content_start] == b'\n' {
165 content_start += 1;
166 }
167
168 let before = &response[last_end..marker_start];
170 let trimmed_before = before.trim();
171 if !trimmed_before.is_empty() {
172 if !unmatched.is_empty() {
173 unmatched.push('\n');
174 }
175 unmatched.push_str(trimmed_before);
176 }
177
178 let close_marker = format!("<!-- /patch:{} -->", name);
180 if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
181 let content = &response[content_start..close_pos];
182 patches.push(PatchBlock {
183 name: name.to_string(),
184 content: content.to_string(),
185 });
186
187 let mut end = close_pos + close_marker.len();
188 if end < len && bytes[end] == b'\n' {
189 end += 1;
190 }
191 last_end = end;
192 pos = end;
193 continue;
194 }
195 }
196
197 pos = close;
198 }
199
200 if last_end < len {
202 let trailing = response[last_end..].trim();
203 if !trailing.is_empty() {
204 if !unmatched.is_empty() {
205 unmatched.push('\n');
206 }
207 unmatched.push_str(trailing);
208 }
209 }
210
211 Ok((patches, unmatched))
212}
213
214pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
223 apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
224}
225
226pub fn apply_patches_with_overrides(
229 doc: &str,
230 patches: &[PatchBlock],
231 unmatched: &str,
232 file: &Path,
233 mode_overrides: &std::collections::HashMap<String, String>,
234) -> Result<String> {
235 let summary = file.file_stem().and_then(|s| s.to_str());
240 let mut result = remove_all_boundaries(doc);
241 if let Ok(components) = component::parse(&result)
242 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
243 {
244 let id = crate::new_boundary_id_with_summary(summary);
245 let marker = crate::format_boundary_marker(&id);
246 let content = exchange.content(&result);
247 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
248 result = exchange.replace_content(&result, &new_content);
249 eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
250 }
251
252 let components = component::parse(&result)
254 .context("failed to parse components")?;
255
256 let configs = load_component_configs(file);
258
259 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
264 let mut overflow = String::new();
265 for patch in patches {
266 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
267 ops.push((idx, patch));
268 } else {
269 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
270 eprintln!(
271 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
272 patch.name,
273 available.join(", ")
274 );
275 if !overflow.is_empty() {
276 overflow.push('\n');
277 }
278 overflow.push_str(&patch.content);
279 }
280 }
281
282 ops.sort_by(|a, b| b.0.cmp(&a.0));
284
285 for (idx, patch) in &ops {
286 let comp = &components[*idx];
287 let mode = mode_overrides.get(&patch.name)
289 .map(|s| s.as_str())
290 .or_else(|| comp.patch_mode())
291 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
292 .unwrap_or_else(|| default_mode(&patch.name));
293 if mode == "append"
295 && let Some(bid) = find_boundary_in_component(&result, comp)
296 {
297 result = comp.append_with_boundary(&result, &patch.content, &bid);
298 continue;
299 }
300 let new_content = apply_mode(mode, comp.content(&result), &patch.content);
301 result = comp.replace_content(&result, &new_content);
302 }
303
304 let mut all_unmatched = String::new();
306 if !overflow.is_empty() {
307 all_unmatched.push_str(&overflow);
308 }
309 if !unmatched.is_empty() {
310 if !all_unmatched.is_empty() {
311 all_unmatched.push('\n');
312 }
313 all_unmatched.push_str(unmatched);
314 }
315
316 if !all_unmatched.is_empty() {
318 let unmatched = &all_unmatched;
319 let components = component::parse(&result)
321 .context("failed to re-parse components after patching")?;
322
323 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
324 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
326 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
327 result = output_comp.append_with_boundary(&result, unmatched, &bid);
328 } else {
329 let existing = output_comp.content(&result);
331 let new_content = if existing.trim().is_empty() {
332 format!("{}\n", unmatched)
333 } else {
334 format!("{}{}\n", existing, unmatched)
335 };
336 result = output_comp.replace_content(&result, &new_content);
337 }
338 } else {
339 if !result.ends_with('\n') {
341 result.push('\n');
342 }
343 result.push_str("\n<!-- agent:exchange -->\n");
344 result.push_str(unmatched);
345 result.push_str("\n<!-- /agent:exchange -->\n");
346 }
347 }
348
349 {
355 if let Ok(components) = component::parse(&result)
356 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
357 && find_boundary_in_component(&result, exchange).is_none()
358 {
359 let id = uuid::Uuid::new_v4().to_string();
361 let marker = format!("<!-- agent:boundary:{} -->", id);
362 let content = exchange.content(&result);
363 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
364 result = exchange.replace_content(&result, &new_content);
365 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
366 }
367 }
368
369 Ok(result)
370}
371
372pub fn reposition_boundary_to_end(doc: &str) -> String {
380 reposition_boundary_to_end_with_summary(doc, None)
381}
382
383pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
388 let mut result = remove_all_boundaries(doc);
389 if let Ok(components) = component::parse(&result)
390 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
391 {
392 let id = crate::new_boundary_id_with_summary(summary);
393 let marker = crate::format_boundary_marker(&id);
394 let content = exchange.content(&result);
395 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
396 result = exchange.replace_content(&result, &new_content);
397 }
398 result
399}
400
401fn remove_all_boundaries(doc: &str) -> String {
404 let prefix = "<!-- agent:boundary:";
405 let suffix = " -->";
406 let code_ranges = component::find_code_ranges(doc);
407 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
408 let mut result = String::with_capacity(doc.len());
409 let mut offset = 0;
410 for line in doc.lines() {
411 let trimmed = line.trim();
412 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
413 if is_boundary && !in_code(offset) {
414 offset += line.len() + 1; continue;
417 }
418 result.push_str(line);
419 result.push('\n');
420 offset += line.len() + 1;
421 }
422 if !doc.ends_with('\n') && result.ends_with('\n') {
423 result.pop();
424 }
425 result
426}
427
428fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
430 let prefix = "<!-- agent:boundary:";
431 let suffix = " -->";
432 let content_region = &doc[comp.open_end..comp.close_start];
433 let code_ranges = component::find_code_ranges(doc);
434 let mut search_from = 0;
435 while let Some(start) = content_region[search_from..].find(prefix) {
436 let abs_start = comp.open_end + search_from + start;
437 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
438 search_from += start + prefix.len();
439 continue;
440 }
441 let after_prefix = &content_region[search_from + start + prefix.len()..];
442 if let Some(end) = after_prefix.find(suffix) {
443 return Some(after_prefix[..end].trim().to_string());
444 }
445 break;
446 }
447 None
448}
449
450pub fn template_info(file: &Path) -> Result<TemplateInfo> {
452 let doc = std::fs::read_to_string(file)
453 .with_context(|| format!("failed to read {}", file.display()))?;
454
455 let (fm, _body) = crate::frontmatter::parse(&doc)?;
456 let template_mode = fm.resolve_mode().is_template();
457
458 let components = component::parse(&doc)
459 .with_context(|| format!("failed to parse components in {}", file.display()))?;
460
461 let configs = load_component_configs(file);
462
463 let component_infos: Vec<ComponentInfo> = components
464 .iter()
465 .map(|comp| {
466 let content = comp.content(&doc).to_string();
467 let mode = comp.patch_mode().map(|s| s.to_string())
469 .or_else(|| configs.get(&comp.name).cloned())
470 .unwrap_or_else(|| default_mode(&comp.name).to_string());
471 let line = doc[..comp.open_start].matches('\n').count() + 1;
473 ComponentInfo {
474 name: comp.name.clone(),
475 mode,
476 content,
477 line,
478 max_entries: None, }
480 })
481 .collect();
482
483 Ok(TemplateInfo {
484 template_mode,
485 components: component_infos,
486 })
487}
488
489fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
492 let mut result = std::collections::HashMap::new();
493 let root = find_project_root(file);
494 if let Some(root) = root {
495 let config_path = root.join(".agent-doc/components.toml");
496 if config_path.exists()
497 && let Ok(content) = std::fs::read_to_string(&config_path)
498 && let Ok(table) = content.parse::<toml::Table>()
499 {
500 for (name, value) in &table {
501 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
503 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
504 {
505 result.insert(name.clone(), mode.to_string());
506 }
507 }
508 }
509 }
510 result
511}
512
513fn default_mode(name: &str) -> &'static str {
516 match name {
517 "exchange" | "findings" => "append",
518 _ => "replace",
519 }
520}
521
522fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
524 match mode {
525 "append" => format!("{}{}", existing, new_content),
526 "prepend" => format!("{}{}", new_content, existing),
527 _ => new_content.to_string(), }
529}
530
531fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
532 let canonical = file.canonicalize().ok()?;
533 let mut dir = canonical.parent()?;
534 loop {
535 if dir.join(".agent-doc").is_dir() {
536 return Some(dir.to_path_buf());
537 }
538 dir = dir.parent()?;
539 }
540}
541
542fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
545 let mut search_start = from;
546 loop {
547 let rel = haystack[search_start..].find(needle)?;
548 let abs = search_start + rel;
549 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
550 search_start = abs + needle.len();
552 continue;
553 }
554 return Some(abs);
555 }
556}
557
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use tempfile::TempDir;
563
564 fn setup_project() -> TempDir {
565 let dir = TempDir::new().unwrap();
566 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
567 dir
568 }
569
570 #[test]
571 fn parse_single_patch() {
572 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
573 let (patches, unmatched) = parse_patches(response).unwrap();
574 assert_eq!(patches.len(), 1);
575 assert_eq!(patches[0].name, "status");
576 assert_eq!(patches[0].content, "Build passing.\n");
577 assert!(unmatched.is_empty());
578 }
579
580 #[test]
581 fn parse_multiple_patches() {
582 let response = "\
583<!-- patch:status -->
584All green.
585<!-- /patch:status -->
586
587<!-- patch:log -->
588- New entry
589<!-- /patch:log -->
590";
591 let (patches, unmatched) = parse_patches(response).unwrap();
592 assert_eq!(patches.len(), 2);
593 assert_eq!(patches[0].name, "status");
594 assert_eq!(patches[0].content, "All green.\n");
595 assert_eq!(patches[1].name, "log");
596 assert_eq!(patches[1].content, "- New entry\n");
597 assert!(unmatched.is_empty());
598 }
599
600 #[test]
601 fn parse_with_unmatched_content() {
602 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
603 let (patches, unmatched) = parse_patches(response).unwrap();
604 assert_eq!(patches.len(), 1);
605 assert_eq!(patches[0].name, "status");
606 assert!(unmatched.contains("Some free text."));
607 assert!(unmatched.contains("Trailing text."));
608 }
609
610 #[test]
611 fn parse_empty_response() {
612 let (patches, unmatched) = parse_patches("").unwrap();
613 assert!(patches.is_empty());
614 assert!(unmatched.is_empty());
615 }
616
617 #[test]
618 fn parse_no_patches() {
619 let response = "Just a plain response with no patch blocks.";
620 let (patches, unmatched) = parse_patches(response).unwrap();
621 assert!(patches.is_empty());
622 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
623 }
624
625 #[test]
626 fn apply_patches_replace() {
627 let dir = setup_project();
628 let doc_path = dir.path().join("test.md");
629 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
630 std::fs::write(&doc_path, doc).unwrap();
631
632 let patches = vec![PatchBlock {
633 name: "status".to_string(),
634 content: "new\n".to_string(),
635 }];
636 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
637 assert!(result.contains("new\n"));
638 assert!(!result.contains("\nold\n"));
639 assert!(result.contains("<!-- agent:status -->"));
640 }
641
642 #[test]
643 fn apply_patches_unmatched_creates_exchange() {
644 let dir = setup_project();
645 let doc_path = dir.path().join("test.md");
646 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
647 std::fs::write(&doc_path, doc).unwrap();
648
649 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
650 assert!(result.contains("<!-- agent:exchange -->"));
651 assert!(result.contains("Extra info here"));
652 assert!(result.contains("<!-- /agent:exchange -->"));
653 }
654
655 #[test]
656 fn apply_patches_unmatched_appends_to_existing_exchange() {
657 let dir = setup_project();
658 let doc_path = dir.path().join("test.md");
659 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
660 std::fs::write(&doc_path, doc).unwrap();
661
662 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
663 assert!(result.contains("previous"));
664 assert!(result.contains("new stuff"));
665 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
667 }
668
669 #[test]
670 fn apply_patches_missing_component_routes_to_exchange() {
671 let dir = setup_project();
672 let doc_path = dir.path().join("test.md");
673 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
674 std::fs::write(&doc_path, doc).unwrap();
675
676 let patches = vec![PatchBlock {
677 name: "nonexistent".to_string(),
678 content: "overflow data\n".to_string(),
679 }];
680 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
681 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
683 assert!(result.contains("previous"), "existing exchange content should be preserved");
684 }
685
686 #[test]
687 fn apply_patches_missing_component_creates_exchange() {
688 let dir = setup_project();
689 let doc_path = dir.path().join("test.md");
690 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
691 std::fs::write(&doc_path, doc).unwrap();
692
693 let patches = vec![PatchBlock {
694 name: "nonexistent".to_string(),
695 content: "overflow data\n".to_string(),
696 }];
697 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
698 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
700 assert!(result.contains("overflow data"), "overflow content should be in exchange");
701 }
702
703 #[test]
704 fn is_template_mode_detection() {
705 assert!(is_template_mode(Some("template")));
706 assert!(!is_template_mode(Some("append")));
707 assert!(!is_template_mode(None));
708 }
709
710 #[test]
711 fn template_info_works() {
712 let dir = setup_project();
713 let doc_path = dir.path().join("test.md");
714 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
715 std::fs::write(&doc_path, doc).unwrap();
716
717 let info = template_info(&doc_path).unwrap();
718 assert!(info.template_mode);
719 assert_eq!(info.components.len(), 1);
720 assert_eq!(info.components[0].name, "status");
721 assert_eq!(info.components[0].content, "content\n");
722 }
723
724 #[test]
725 fn template_info_legacy_mode_works() {
726 let dir = setup_project();
727 let doc_path = dir.path().join("test.md");
728 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
729 std::fs::write(&doc_path, doc).unwrap();
730
731 let info = template_info(&doc_path).unwrap();
732 assert!(info.template_mode);
733 }
734
735 #[test]
736 fn template_info_append_mode() {
737 let dir = setup_project();
738 let doc_path = dir.path().join("test.md");
739 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
740 std::fs::write(&doc_path, doc).unwrap();
741
742 let info = template_info(&doc_path).unwrap();
743 assert!(!info.template_mode);
744 assert!(info.components.is_empty());
745 }
746
747 #[test]
748 fn parse_patches_ignores_markers_in_fenced_code_block() {
749 let response = "\
750<!-- patch:exchange -->
751Here is how you use component markers:
752
753```markdown
754<!-- agent:exchange -->
755example content
756<!-- /agent:exchange -->
757```
758
759<!-- /patch:exchange -->
760";
761 let (patches, unmatched) = parse_patches(response).unwrap();
762 assert_eq!(patches.len(), 1);
763 assert_eq!(patches[0].name, "exchange");
764 assert!(patches[0].content.contains("```markdown"));
765 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
766 assert!(unmatched.is_empty());
767 }
768
769 #[test]
770 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
771 let response = "\
773<!-- patch:exchange -->
774Real content here.
775
776```markdown
777<!-- patch:fake -->
778This is just an example.
779<!-- /patch:fake -->
780```
781
782<!-- /patch:exchange -->
783";
784 let (patches, unmatched) = parse_patches(response).unwrap();
785 assert_eq!(patches.len(), 1, "should only find the outer real patch");
786 assert_eq!(patches[0].name, "exchange");
787 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
788 assert!(unmatched.is_empty());
789 }
790
791 #[test]
792 fn parse_patches_ignores_markers_in_tilde_fence() {
793 let response = "\
794<!-- patch:status -->
795OK
796<!-- /patch:status -->
797
798~~~
799<!-- patch:fake -->
800example
801<!-- /patch:fake -->
802~~~
803";
804 let (patches, _unmatched) = parse_patches(response).unwrap();
805 assert_eq!(patches.len(), 1);
807 assert_eq!(patches[0].name, "status");
808 }
809
810 #[test]
811 fn parse_patches_ignores_closing_marker_in_code_block() {
812 let response = "\
815<!-- patch:exchange -->
816Example:
817
818```
819<!-- /patch:exchange -->
820```
821
822Real content continues.
823<!-- /patch:exchange -->
824";
825 let (patches, unmatched) = parse_patches(response).unwrap();
826 assert_eq!(patches.len(), 1);
827 assert_eq!(patches[0].name, "exchange");
828 assert!(patches[0].content.contains("Real content continues."));
829 }
830
831 #[test]
832 fn parse_patches_normal_markers_still_work() {
833 let response = "\
835<!-- patch:status -->
836All systems go.
837<!-- /patch:status -->
838<!-- patch:log -->
839- Entry 1
840<!-- /patch:log -->
841";
842 let (patches, unmatched) = parse_patches(response).unwrap();
843 assert_eq!(patches.len(), 2);
844 assert_eq!(patches[0].name, "status");
845 assert_eq!(patches[0].content, "All systems go.\n");
846 assert_eq!(patches[1].name, "log");
847 assert_eq!(patches[1].content, "- Entry 1\n");
848 assert!(unmatched.is_empty());
849 }
850
851 #[test]
854 fn inline_attr_mode_overrides_config() {
855 let dir = setup_project();
857 let doc_path = dir.path().join("test.md");
858 std::fs::write(
860 dir.path().join(".agent-doc/components.toml"),
861 "[status]\nmode = \"append\"\n",
862 ).unwrap();
863 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
865 std::fs::write(&doc_path, doc).unwrap();
866
867 let patches = vec![PatchBlock {
868 name: "status".to_string(),
869 content: "new\n".to_string(),
870 }];
871 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
872 assert!(result.contains("new\n"));
874 assert!(!result.contains("old\n"));
875 }
876
877 #[test]
878 fn inline_attr_mode_overrides_default() {
879 let dir = setup_project();
881 let doc_path = dir.path().join("test.md");
882 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
883 std::fs::write(&doc_path, doc).unwrap();
884
885 let patches = vec![PatchBlock {
886 name: "exchange".to_string(),
887 content: "new\n".to_string(),
888 }];
889 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
890 assert!(result.contains("new\n"));
891 assert!(!result.contains("old\n"));
892 }
893
894 #[test]
895 fn no_inline_attr_falls_back_to_config() {
896 let dir = setup_project();
898 let doc_path = dir.path().join("test.md");
899 std::fs::write(
900 dir.path().join(".agent-doc/components.toml"),
901 "[status]\nmode = \"append\"\n",
902 ).unwrap();
903 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
904 std::fs::write(&doc_path, doc).unwrap();
905
906 let patches = vec![PatchBlock {
907 name: "status".to_string(),
908 content: "new\n".to_string(),
909 }];
910 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
911 assert!(result.contains("old\n"));
913 assert!(result.contains("new\n"));
914 }
915
916 #[test]
917 fn no_inline_attr_no_config_falls_back_to_default() {
918 let dir = setup_project();
920 let doc_path = dir.path().join("test.md");
921 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
922 std::fs::write(&doc_path, doc).unwrap();
923
924 let patches = vec![PatchBlock {
925 name: "exchange".to_string(),
926 content: "new\n".to_string(),
927 }];
928 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
929 assert!(result.contains("old\n"));
931 assert!(result.contains("new\n"));
932 }
933
934 #[test]
935 fn inline_patch_attr_overrides_config() {
936 let dir = setup_project();
938 let doc_path = dir.path().join("test.md");
939 std::fs::write(
940 dir.path().join(".agent-doc/components.toml"),
941 "[status]\nmode = \"append\"\n",
942 ).unwrap();
943 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
944 std::fs::write(&doc_path, doc).unwrap();
945
946 let patches = vec![PatchBlock {
947 name: "status".to_string(),
948 content: "new\n".to_string(),
949 }];
950 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
951 assert!(result.contains("new\n"));
952 assert!(!result.contains("old\n"));
953 }
954
955 #[test]
956 fn inline_patch_attr_overrides_mode_attr() {
957 let dir = setup_project();
959 let doc_path = dir.path().join("test.md");
960 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
961 std::fs::write(&doc_path, doc).unwrap();
962
963 let patches = vec![PatchBlock {
964 name: "exchange".to_string(),
965 content: "new\n".to_string(),
966 }];
967 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
968 assert!(result.contains("new\n"));
969 assert!(!result.contains("old\n"));
970 }
971
972 #[test]
973 fn toml_patch_key_works() {
974 let dir = setup_project();
976 let doc_path = dir.path().join("test.md");
977 std::fs::write(
978 dir.path().join(".agent-doc/components.toml"),
979 "[status]\npatch = \"append\"\n",
980 ).unwrap();
981 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
982 std::fs::write(&doc_path, doc).unwrap();
983
984 let patches = vec![PatchBlock {
985 name: "status".to_string(),
986 content: "new\n".to_string(),
987 }];
988 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
989 assert!(result.contains("old\n"));
990 assert!(result.contains("new\n"));
991 }
992
993 #[test]
994 fn stream_override_beats_inline_attr() {
995 let dir = setup_project();
997 let doc_path = dir.path().join("test.md");
998 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
999 std::fs::write(&doc_path, doc).unwrap();
1000
1001 let patches = vec![PatchBlock {
1002 name: "exchange".to_string(),
1003 content: "new\n".to_string(),
1004 }];
1005 let mut overrides = std::collections::HashMap::new();
1006 overrides.insert("exchange".to_string(), "replace".to_string());
1007 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1008 assert!(result.contains("new\n"));
1010 assert!(!result.contains("old\n"));
1011 }
1012
1013 #[test]
1014 fn apply_patches_ignores_component_tags_in_code_blocks() {
1015 let dir = setup_project();
1018 let doc_path = dir.path().join("test.md");
1019 let doc = "\
1020# Scaffold Guide
1021
1022Here is an example of a component:
1023
1024```markdown
1025<!-- agent:status -->
1026example scaffold content
1027<!-- /agent:status -->
1028```
1029
1030<!-- agent:status -->
1031real status content
1032<!-- /agent:status -->
1033";
1034 std::fs::write(&doc_path, doc).unwrap();
1035
1036 let patches = vec![PatchBlock {
1037 name: "status".to_string(),
1038 content: "patched status\n".to_string(),
1039 }];
1040 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1041
1042 assert!(result.contains("patched status\n"), "real component should receive the patch");
1044 assert!(result.contains("example scaffold content"), "code block content should be preserved");
1046 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1048 }
1049
1050 #[test]
1051 fn unmatched_content_uses_boundary_marker() {
1052 let dir = setup_project();
1053 let file = dir.path().join("test.md");
1054 let doc = concat!(
1055 "---\nagent_doc_format: template\n---\n",
1056 "<!-- agent:exchange patch=append -->\n",
1057 "User prompt here.\n",
1058 "<!-- agent:boundary:test-uuid-123 -->\n",
1059 "<!-- /agent:exchange -->\n",
1060 );
1061 std::fs::write(&file, doc).unwrap();
1062
1063 let patches = vec![];
1065 let unmatched = "### Re: Response\n\nResponse content here.\n";
1066
1067 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1068
1069 let prompt_pos = result.find("User prompt here.").unwrap();
1071 let response_pos = result.find("### Re: Response").unwrap();
1072 assert!(
1073 response_pos > prompt_pos,
1074 "response should appear after the user prompt (boundary insertion)"
1075 );
1076
1077 assert!(
1079 !result.contains("test-uuid-123"),
1080 "boundary marker should be consumed after insertion"
1081 );
1082 }
1083
1084 #[test]
1085 fn explicit_patch_uses_boundary_marker() {
1086 let dir = setup_project();
1087 let file = dir.path().join("test.md");
1088 let doc = concat!(
1089 "---\nagent_doc_format: template\n---\n",
1090 "<!-- agent:exchange patch=append -->\n",
1091 "User prompt here.\n",
1092 "<!-- agent:boundary:patch-uuid-456 -->\n",
1093 "<!-- /agent:exchange -->\n",
1094 );
1095 std::fs::write(&file, doc).unwrap();
1096
1097 let patches = vec![PatchBlock {
1099 name: "exchange".to_string(),
1100 content: "### Re: Response\n\nResponse content.\n".to_string(),
1101 }];
1102
1103 let result = apply_patches(doc, &patches, "", &file).unwrap();
1104
1105 let prompt_pos = result.find("User prompt here.").unwrap();
1107 let response_pos = result.find("### Re: Response").unwrap();
1108 assert!(
1109 response_pos > prompt_pos,
1110 "response should appear after user prompt"
1111 );
1112
1113 assert!(
1115 !result.contains("patch-uuid-456"),
1116 "boundary marker should be consumed by explicit patch"
1117 );
1118 }
1119
1120 #[test]
1121 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1122 let dir = setup_project();
1125 let file = dir.path().join("test.md");
1126 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1128 std::fs::write(&file, doc).unwrap();
1129
1130 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1131 let (patches, unmatched) = parse_patches(response).unwrap();
1132 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1133
1134 assert!(
1136 result.contains("<!-- agent:boundary:"),
1137 "boundary must be re-inserted even when original doc had no boundary: {result}"
1138 );
1139 }
1140
1141 #[test]
1142 fn boundary_survives_multiple_cycles() {
1143 let dir = setup_project();
1145 let file = dir.path().join("test.md");
1146 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1147 std::fs::write(&file, doc).unwrap();
1148
1149 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1151 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1152 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1153 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1154
1155 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1157 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1158 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1159 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1160 }
1161
1162 #[test]
1163 fn remove_all_boundaries_skips_code_blocks() {
1164 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1165 let result = remove_all_boundaries(doc);
1166 assert!(
1168 result.contains("<!-- agent:boundary:fake-id -->"),
1169 "boundary inside code block must be preserved: {result}"
1170 );
1171 assert!(
1173 !result.contains("<!-- agent:boundary:real-id -->"),
1174 "boundary outside code block must be removed: {result}"
1175 );
1176 }
1177
1178 #[test]
1179 fn reposition_boundary_moves_to_end() {
1180 let doc = "\
1181<!-- agent:exchange -->
1182Previous response.
1183<!-- agent:boundary:old-id -->
1184User prompt here.
1185<!-- /agent:exchange -->";
1186 let result = reposition_boundary_to_end(doc);
1187 assert!(!result.contains("old-id"), "old boundary should be removed");
1189 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1191 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1193 let prompt_pos = result.find("User prompt here.").unwrap();
1194 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1195 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1196 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1197 }
1198
1199 #[test]
1200 fn reposition_boundary_no_exchange_unchanged() {
1201 let doc = "\
1202<!-- agent:output -->
1203Some content.
1204<!-- /agent:output -->";
1205 let result = reposition_boundary_to_end(doc);
1206 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1207 }
1208}