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 #[allow(dead_code)]
92 pub attrs: std::collections::HashMap<String, String>,
93}
94
95impl PatchBlock {
96 pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
98 PatchBlock {
99 name: name.into(),
100 content: content.into(),
101 attrs: std::collections::HashMap::new(),
102 }
103 }
104}
105
106#[derive(Debug, Serialize)]
108pub struct TemplateInfo {
109 pub template_mode: bool,
110 pub components: Vec<ComponentInfo>,
111}
112
113#[derive(Debug, Serialize)]
115pub struct ComponentInfo {
116 pub name: String,
117 pub mode: String,
118 pub content: String,
119 pub line: usize,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub max_entries: Option<usize>,
122}
123
124#[cfg(test)]
126pub fn is_template_mode(mode: Option<&str>) -> bool {
127 matches!(mode, Some("template"))
128}
129
130pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
135 let bytes = response.as_bytes();
136 let len = bytes.len();
137 let code_ranges = component::find_code_ranges(response);
138 let mut patches = Vec::new();
139 let mut unmatched = String::new();
140 let mut pos = 0;
141 let mut last_end = 0;
142
143 while pos + 4 <= len {
144 if &bytes[pos..pos + 4] != b"<!--" {
145 pos += 1;
146 continue;
147 }
148
149 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
151 pos += 4;
152 continue;
153 }
154
155 let marker_start = pos;
156
157 let close = match find_comment_end(bytes, pos + 4) {
159 Some(c) => c,
160 None => {
161 pos += 4;
162 continue;
163 }
164 };
165
166 let inner = &response[marker_start + 4..close - 3];
167 let trimmed = inner.trim();
168
169 if let Some(rest) = trimmed.strip_prefix("patch:") {
170 let rest = rest.trim();
171 if rest.is_empty() || rest.starts_with('/') {
172 pos = close;
173 continue;
174 }
175
176 let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
178 let name = &rest[..space_idx];
179 let attr_text = rest[space_idx..].trim();
180 (name, component::parse_attrs(attr_text))
181 } else {
182 (rest, std::collections::HashMap::new())
183 };
184
185 let mut content_start = close;
187 if content_start < len && bytes[content_start] == b'\n' {
188 content_start += 1;
189 }
190
191 let before = &response[last_end..marker_start];
193 let trimmed_before = before.trim();
194 if !trimmed_before.is_empty() {
195 if !unmatched.is_empty() {
196 unmatched.push('\n');
197 }
198 unmatched.push_str(trimmed_before);
199 }
200
201 let close_marker = format!("<!-- /patch:{} -->", name);
203 if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
204 let content = &response[content_start..close_pos];
205 patches.push(PatchBlock {
206 name: name.to_string(),
207 content: content.to_string(),
208 attrs,
209 });
210
211 let mut end = close_pos + close_marker.len();
212 if end < len && bytes[end] == b'\n' {
213 end += 1;
214 }
215 last_end = end;
216 pos = end;
217 continue;
218 }
219 }
220
221 pos = close;
222 }
223
224 if last_end < len {
226 let trailing = response[last_end..].trim();
227 if !trailing.is_empty() {
228 if !unmatched.is_empty() {
229 unmatched.push('\n');
230 }
231 unmatched.push_str(trailing);
232 }
233 }
234
235 Ok((patches, unmatched))
236}
237
238pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
247 apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
248}
249
250pub fn apply_patches_with_overrides(
253 doc: &str,
254 patches: &[PatchBlock],
255 unmatched: &str,
256 file: &Path,
257 mode_overrides: &std::collections::HashMap<String, String>,
258) -> Result<String> {
259 let summary = file.file_stem().and_then(|s| s.to_str());
264 let mut result = remove_all_boundaries(doc);
265 if let Ok(components) = component::parse(&result)
266 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
267 {
268 let id = crate::new_boundary_id_with_summary(summary);
269 let marker = crate::format_boundary_marker(&id);
270 let content = exchange.content(&result);
271 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
272 result = exchange.replace_content(&result, &new_content);
273 eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
274 }
275
276 let components = component::parse(&result)
278 .context("failed to parse components")?;
279
280 let configs = load_component_configs(file);
282
283 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
288 let mut overflow = String::new();
289 for patch in patches {
290 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
291 ops.push((idx, patch));
292 } else {
293 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
294 eprintln!(
295 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
296 patch.name,
297 available.join(", ")
298 );
299 if !overflow.is_empty() {
300 overflow.push('\n');
301 }
302 overflow.push_str(&patch.content);
303 }
304 }
305
306 ops.sort_by(|a, b| b.0.cmp(&a.0));
308
309 for (idx, patch) in &ops {
310 let comp = &components[*idx];
311 let mode = mode_overrides.get(&patch.name)
313 .map(|s| s.as_str())
314 .or_else(|| comp.patch_mode())
315 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
316 .unwrap_or_else(|| default_mode(&patch.name));
317 if mode == "append"
319 && let Some(bid) = find_boundary_in_component(&result, comp)
320 {
321 result = comp.append_with_boundary(&result, &patch.content, &bid);
322 continue;
323 }
324 let new_content = apply_mode(mode, comp.content(&result), &patch.content);
325 result = comp.replace_content(&result, &new_content);
326 }
327
328 let mut all_unmatched = String::new();
330 if !overflow.is_empty() {
331 all_unmatched.push_str(&overflow);
332 }
333 if !unmatched.is_empty() {
334 if !all_unmatched.is_empty() {
335 all_unmatched.push('\n');
336 }
337 all_unmatched.push_str(unmatched);
338 }
339
340 if !all_unmatched.is_empty() {
342 let unmatched = &all_unmatched;
343 let components = component::parse(&result)
345 .context("failed to re-parse components after patching")?;
346
347 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
348 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
350 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
351 result = output_comp.append_with_boundary(&result, unmatched, &bid);
352 } else {
353 let existing = output_comp.content(&result);
355 let new_content = if existing.trim().is_empty() {
356 format!("{}\n", unmatched)
357 } else {
358 format!("{}{}\n", existing, unmatched)
359 };
360 result = output_comp.replace_content(&result, &new_content);
361 }
362 } else {
363 if !result.ends_with('\n') {
365 result.push('\n');
366 }
367 result.push_str("\n<!-- agent:exchange -->\n");
368 result.push_str(unmatched);
369 result.push_str("\n<!-- /agent:exchange -->\n");
370 }
371 }
372
373 {
379 let max_lines_configs = load_max_lines_configs(file);
380 'stability: for _ in 0..3 {
381 let Ok(components) = component::parse(&result) else { break };
382 for comp in &components {
383 let max_lines = comp
384 .attrs
385 .get("max_lines")
386 .and_then(|s| s.parse::<usize>().ok())
387 .or_else(|| max_lines_configs.get(&comp.name).copied())
388 .unwrap_or(0);
389 if max_lines > 0 {
390 let content = comp.content(&result);
391 let trimmed = limit_lines(content, max_lines);
392 if trimmed.len() != content.len() {
393 let trimmed = format!("{}\n", trimmed.trim_end());
394 result = comp.replace_content(&result, &trimmed);
395 continue 'stability;
397 }
398 }
399 }
400 break; }
402 }
403
404 {
410 if let Ok(components) = component::parse(&result)
411 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
412 && find_boundary_in_component(&result, exchange).is_none()
413 {
414 let id = uuid::Uuid::new_v4().to_string();
416 let marker = format!("<!-- agent:boundary:{} -->", id);
417 let content = exchange.content(&result);
418 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
419 result = exchange.replace_content(&result, &new_content);
420 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
421 }
422 }
423
424 Ok(result)
425}
426
427pub fn reposition_boundary_to_end(doc: &str) -> String {
435 reposition_boundary_to_end_with_summary(doc, None)
436}
437
438pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
443 let mut result = remove_all_boundaries(doc);
444 if let Ok(components) = component::parse(&result)
445 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
446 {
447 let id = crate::new_boundary_id_with_summary(summary);
448 let marker = crate::format_boundary_marker(&id);
449 let content = exchange.content(&result);
450 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
451 result = exchange.replace_content(&result, &new_content);
452 }
453 result
454}
455
456fn remove_all_boundaries(doc: &str) -> String {
459 let prefix = "<!-- agent:boundary:";
460 let suffix = " -->";
461 let code_ranges = component::find_code_ranges(doc);
462 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
463 let mut result = String::with_capacity(doc.len());
464 let mut offset = 0;
465 for line in doc.lines() {
466 let trimmed = line.trim();
467 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
468 if is_boundary && !in_code(offset) {
469 offset += line.len() + 1; continue;
472 }
473 result.push_str(line);
474 result.push('\n');
475 offset += line.len() + 1;
476 }
477 if !doc.ends_with('\n') && result.ends_with('\n') {
478 result.pop();
479 }
480 result
481}
482
483fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
485 let prefix = "<!-- agent:boundary:";
486 let suffix = " -->";
487 let content_region = &doc[comp.open_end..comp.close_start];
488 let code_ranges = component::find_code_ranges(doc);
489 let mut search_from = 0;
490 while let Some(start) = content_region[search_from..].find(prefix) {
491 let abs_start = comp.open_end + search_from + start;
492 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
493 search_from += start + prefix.len();
494 continue;
495 }
496 let after_prefix = &content_region[search_from + start + prefix.len()..];
497 if let Some(end) = after_prefix.find(suffix) {
498 return Some(after_prefix[..end].trim().to_string());
499 }
500 break;
501 }
502 None
503}
504
505pub fn template_info(file: &Path) -> Result<TemplateInfo> {
507 let doc = std::fs::read_to_string(file)
508 .with_context(|| format!("failed to read {}", file.display()))?;
509
510 let (fm, _body) = crate::frontmatter::parse(&doc)?;
511 let template_mode = fm.resolve_mode().is_template();
512
513 let components = component::parse(&doc)
514 .with_context(|| format!("failed to parse components in {}", file.display()))?;
515
516 let configs = load_component_configs(file);
517
518 let component_infos: Vec<ComponentInfo> = components
519 .iter()
520 .map(|comp| {
521 let content = comp.content(&doc).to_string();
522 let mode = comp.patch_mode().map(|s| s.to_string())
524 .or_else(|| configs.get(&comp.name).cloned())
525 .unwrap_or_else(|| default_mode(&comp.name).to_string());
526 let line = doc[..comp.open_start].matches('\n').count() + 1;
528 ComponentInfo {
529 name: comp.name.clone(),
530 mode,
531 content,
532 line,
533 max_entries: None, }
535 })
536 .collect();
537
538 Ok(TemplateInfo {
539 template_mode,
540 components: component_infos,
541 })
542}
543
544fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
547 let mut result = std::collections::HashMap::new();
548 let root = find_project_root(file);
549 if let Some(root) = root {
550 let config_path = root.join(".agent-doc/components.toml");
551 if config_path.exists()
552 && let Ok(content) = std::fs::read_to_string(&config_path)
553 && let Ok(table) = content.parse::<toml::Table>()
554 {
555 for (name, value) in &table {
556 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
558 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
559 {
560 result.insert(name.clone(), mode.to_string());
561 }
562 }
563 }
564 }
565 result
566}
567
568fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
570 let mut result = std::collections::HashMap::new();
571 let root = find_project_root(file);
572 if let Some(root) = root {
573 let config_path = root.join(".agent-doc/components.toml");
574 if config_path.exists()
575 && let Ok(content) = std::fs::read_to_string(&config_path)
576 && let Ok(table) = content.parse::<toml::Table>()
577 {
578 for (name, value) in &table {
579 if let Some(max_lines) = value.get("max_lines").and_then(|v| v.as_integer())
580 && max_lines > 0
581 {
582 result.insert(name.clone(), max_lines as usize);
583 }
584 }
585 }
586 }
587 result
588}
589
590fn default_mode(name: &str) -> &'static str {
593 match name {
594 "exchange" | "findings" => "append",
595 _ => "replace",
596 }
597}
598
599fn limit_lines(content: &str, max_lines: usize) -> String {
601 let lines: Vec<&str> = content.lines().collect();
602 if lines.len() <= max_lines {
603 return content.to_string();
604 }
605 lines[lines.len() - max_lines..].join("\n")
606}
607
608fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
610 match mode {
611 "append" => format!("{}{}", existing, new_content),
612 "prepend" => format!("{}{}", new_content, existing),
613 _ => new_content.to_string(), }
615}
616
617fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
618 let canonical = file.canonicalize().ok()?;
619 let mut dir = canonical.parent()?;
620 loop {
621 if dir.join(".agent-doc").is_dir() {
622 return Some(dir.to_path_buf());
623 }
624 dir = dir.parent()?;
625 }
626}
627
628fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
631 let mut search_start = from;
632 loop {
633 let rel = haystack[search_start..].find(needle)?;
634 let abs = search_start + rel;
635 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
636 search_start = abs + needle.len();
638 continue;
639 }
640 return Some(abs);
641 }
642}
643
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use tempfile::TempDir;
649
650 fn setup_project() -> TempDir {
651 let dir = TempDir::new().unwrap();
652 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
653 dir
654 }
655
656 #[test]
657 fn parse_single_patch() {
658 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
659 let (patches, unmatched) = parse_patches(response).unwrap();
660 assert_eq!(patches.len(), 1);
661 assert_eq!(patches[0].name, "status");
662 assert_eq!(patches[0].content, "Build passing.\n");
663 assert!(unmatched.is_empty());
664 }
665
666 #[test]
667 fn parse_multiple_patches() {
668 let response = "\
669<!-- patch:status -->
670All green.
671<!-- /patch:status -->
672
673<!-- patch:log -->
674- New entry
675<!-- /patch:log -->
676";
677 let (patches, unmatched) = parse_patches(response).unwrap();
678 assert_eq!(patches.len(), 2);
679 assert_eq!(patches[0].name, "status");
680 assert_eq!(patches[0].content, "All green.\n");
681 assert_eq!(patches[1].name, "log");
682 assert_eq!(patches[1].content, "- New entry\n");
683 assert!(unmatched.is_empty());
684 }
685
686 #[test]
687 fn parse_with_unmatched_content() {
688 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
689 let (patches, unmatched) = parse_patches(response).unwrap();
690 assert_eq!(patches.len(), 1);
691 assert_eq!(patches[0].name, "status");
692 assert!(unmatched.contains("Some free text."));
693 assert!(unmatched.contains("Trailing text."));
694 }
695
696 #[test]
697 fn parse_empty_response() {
698 let (patches, unmatched) = parse_patches("").unwrap();
699 assert!(patches.is_empty());
700 assert!(unmatched.is_empty());
701 }
702
703 #[test]
704 fn parse_no_patches() {
705 let response = "Just a plain response with no patch blocks.";
706 let (patches, unmatched) = parse_patches(response).unwrap();
707 assert!(patches.is_empty());
708 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
709 }
710
711 #[test]
712 fn apply_patches_replace() {
713 let dir = setup_project();
714 let doc_path = dir.path().join("test.md");
715 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
716 std::fs::write(&doc_path, doc).unwrap();
717
718 let patches = vec![PatchBlock {
719 name: "status".to_string(),
720 content: "new\n".to_string(),
721 attrs: Default::default(),
722 }];
723 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
724 assert!(result.contains("new\n"));
725 assert!(!result.contains("\nold\n"));
726 assert!(result.contains("<!-- agent:status -->"));
727 }
728
729 #[test]
730 fn apply_patches_unmatched_creates_exchange() {
731 let dir = setup_project();
732 let doc_path = dir.path().join("test.md");
733 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
734 std::fs::write(&doc_path, doc).unwrap();
735
736 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
737 assert!(result.contains("<!-- agent:exchange -->"));
738 assert!(result.contains("Extra info here"));
739 assert!(result.contains("<!-- /agent:exchange -->"));
740 }
741
742 #[test]
743 fn apply_patches_unmatched_appends_to_existing_exchange() {
744 let dir = setup_project();
745 let doc_path = dir.path().join("test.md");
746 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
747 std::fs::write(&doc_path, doc).unwrap();
748
749 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
750 assert!(result.contains("previous"));
751 assert!(result.contains("new stuff"));
752 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
754 }
755
756 #[test]
757 fn apply_patches_missing_component_routes_to_exchange() {
758 let dir = setup_project();
759 let doc_path = dir.path().join("test.md");
760 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
761 std::fs::write(&doc_path, doc).unwrap();
762
763 let patches = vec![PatchBlock {
764 name: "nonexistent".to_string(),
765 content: "overflow data\n".to_string(),
766 attrs: Default::default(),
767 }];
768 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
769 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
771 assert!(result.contains("previous"), "existing exchange content should be preserved");
772 }
773
774 #[test]
775 fn apply_patches_missing_component_creates_exchange() {
776 let dir = setup_project();
777 let doc_path = dir.path().join("test.md");
778 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
779 std::fs::write(&doc_path, doc).unwrap();
780
781 let patches = vec![PatchBlock {
782 name: "nonexistent".to_string(),
783 content: "overflow data\n".to_string(),
784 attrs: Default::default(),
785 }];
786 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
787 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
789 assert!(result.contains("overflow data"), "overflow content should be in exchange");
790 }
791
792 #[test]
793 fn is_template_mode_detection() {
794 assert!(is_template_mode(Some("template")));
795 assert!(!is_template_mode(Some("append")));
796 assert!(!is_template_mode(None));
797 }
798
799 #[test]
800 fn template_info_works() {
801 let dir = setup_project();
802 let doc_path = dir.path().join("test.md");
803 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
804 std::fs::write(&doc_path, doc).unwrap();
805
806 let info = template_info(&doc_path).unwrap();
807 assert!(info.template_mode);
808 assert_eq!(info.components.len(), 1);
809 assert_eq!(info.components[0].name, "status");
810 assert_eq!(info.components[0].content, "content\n");
811 }
812
813 #[test]
814 fn template_info_legacy_mode_works() {
815 let dir = setup_project();
816 let doc_path = dir.path().join("test.md");
817 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
818 std::fs::write(&doc_path, doc).unwrap();
819
820 let info = template_info(&doc_path).unwrap();
821 assert!(info.template_mode);
822 }
823
824 #[test]
825 fn template_info_append_mode() {
826 let dir = setup_project();
827 let doc_path = dir.path().join("test.md");
828 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
829 std::fs::write(&doc_path, doc).unwrap();
830
831 let info = template_info(&doc_path).unwrap();
832 assert!(!info.template_mode);
833 assert!(info.components.is_empty());
834 }
835
836 #[test]
837 fn parse_patches_ignores_markers_in_fenced_code_block() {
838 let response = "\
839<!-- patch:exchange -->
840Here is how you use component markers:
841
842```markdown
843<!-- agent:exchange -->
844example content
845<!-- /agent:exchange -->
846```
847
848<!-- /patch:exchange -->
849";
850 let (patches, unmatched) = parse_patches(response).unwrap();
851 assert_eq!(patches.len(), 1);
852 assert_eq!(patches[0].name, "exchange");
853 assert!(patches[0].content.contains("```markdown"));
854 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
855 assert!(unmatched.is_empty());
856 }
857
858 #[test]
859 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
860 let response = "\
862<!-- patch:exchange -->
863Real content here.
864
865```markdown
866<!-- patch:fake -->
867This is just an example.
868<!-- /patch:fake -->
869```
870
871<!-- /patch:exchange -->
872";
873 let (patches, unmatched) = parse_patches(response).unwrap();
874 assert_eq!(patches.len(), 1, "should only find the outer real patch");
875 assert_eq!(patches[0].name, "exchange");
876 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
877 assert!(unmatched.is_empty());
878 }
879
880 #[test]
881 fn parse_patches_ignores_markers_in_tilde_fence() {
882 let response = "\
883<!-- patch:status -->
884OK
885<!-- /patch:status -->
886
887~~~
888<!-- patch:fake -->
889example
890<!-- /patch:fake -->
891~~~
892";
893 let (patches, _unmatched) = parse_patches(response).unwrap();
894 assert_eq!(patches.len(), 1);
896 assert_eq!(patches[0].name, "status");
897 }
898
899 #[test]
900 fn parse_patches_ignores_closing_marker_in_code_block() {
901 let response = "\
904<!-- patch:exchange -->
905Example:
906
907```
908<!-- /patch:exchange -->
909```
910
911Real content continues.
912<!-- /patch:exchange -->
913";
914 let (patches, _unmatched) = parse_patches(response).unwrap();
915 assert_eq!(patches.len(), 1);
916 assert_eq!(patches[0].name, "exchange");
917 assert!(patches[0].content.contains("Real content continues."));
918 }
919
920 #[test]
921 fn parse_patches_normal_markers_still_work() {
922 let response = "\
924<!-- patch:status -->
925All systems go.
926<!-- /patch:status -->
927<!-- patch:log -->
928- Entry 1
929<!-- /patch:log -->
930";
931 let (patches, unmatched) = parse_patches(response).unwrap();
932 assert_eq!(patches.len(), 2);
933 assert_eq!(patches[0].name, "status");
934 assert_eq!(patches[0].content, "All systems go.\n");
935 assert_eq!(patches[1].name, "log");
936 assert_eq!(patches[1].content, "- Entry 1\n");
937 assert!(unmatched.is_empty());
938 }
939
940 #[test]
943 fn inline_attr_mode_overrides_config() {
944 let dir = setup_project();
946 let doc_path = dir.path().join("test.md");
947 std::fs::write(
949 dir.path().join(".agent-doc/components.toml"),
950 "[status]\nmode = \"append\"\n",
951 ).unwrap();
952 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
954 std::fs::write(&doc_path, doc).unwrap();
955
956 let patches = vec![PatchBlock {
957 name: "status".to_string(),
958 content: "new\n".to_string(),
959 attrs: Default::default(),
960 }];
961 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
962 assert!(result.contains("new\n"));
964 assert!(!result.contains("old\n"));
965 }
966
967 #[test]
968 fn inline_attr_mode_overrides_default() {
969 let dir = setup_project();
971 let doc_path = dir.path().join("test.md");
972 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
973 std::fs::write(&doc_path, doc).unwrap();
974
975 let patches = vec![PatchBlock {
976 name: "exchange".to_string(),
977 content: "new\n".to_string(),
978 attrs: Default::default(),
979 }];
980 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
981 assert!(result.contains("new\n"));
982 assert!(!result.contains("old\n"));
983 }
984
985 #[test]
986 fn no_inline_attr_falls_back_to_config() {
987 let dir = setup_project();
989 let doc_path = dir.path().join("test.md");
990 std::fs::write(
991 dir.path().join(".agent-doc/components.toml"),
992 "[status]\nmode = \"append\"\n",
993 ).unwrap();
994 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
995 std::fs::write(&doc_path, doc).unwrap();
996
997 let patches = vec![PatchBlock {
998 name: "status".to_string(),
999 content: "new\n".to_string(),
1000 attrs: Default::default(),
1001 }];
1002 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1003 assert!(result.contains("old\n"));
1005 assert!(result.contains("new\n"));
1006 }
1007
1008 #[test]
1009 fn no_inline_attr_no_config_falls_back_to_default() {
1010 let dir = setup_project();
1012 let doc_path = dir.path().join("test.md");
1013 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1014 std::fs::write(&doc_path, doc).unwrap();
1015
1016 let patches = vec![PatchBlock {
1017 name: "exchange".to_string(),
1018 content: "new\n".to_string(),
1019 attrs: Default::default(),
1020 }];
1021 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1022 assert!(result.contains("old\n"));
1024 assert!(result.contains("new\n"));
1025 }
1026
1027 #[test]
1028 fn inline_patch_attr_overrides_config() {
1029 let dir = setup_project();
1031 let doc_path = dir.path().join("test.md");
1032 std::fs::write(
1033 dir.path().join(".agent-doc/components.toml"),
1034 "[status]\nmode = \"append\"\n",
1035 ).unwrap();
1036 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1037 std::fs::write(&doc_path, doc).unwrap();
1038
1039 let patches = vec![PatchBlock {
1040 name: "status".to_string(),
1041 content: "new\n".to_string(),
1042 attrs: Default::default(),
1043 }];
1044 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1045 assert!(result.contains("new\n"));
1046 assert!(!result.contains("old\n"));
1047 }
1048
1049 #[test]
1050 fn inline_patch_attr_overrides_mode_attr() {
1051 let dir = setup_project();
1053 let doc_path = dir.path().join("test.md");
1054 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1055 std::fs::write(&doc_path, doc).unwrap();
1056
1057 let patches = vec![PatchBlock {
1058 name: "exchange".to_string(),
1059 content: "new\n".to_string(),
1060 attrs: Default::default(),
1061 }];
1062 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1063 assert!(result.contains("new\n"));
1064 assert!(!result.contains("old\n"));
1065 }
1066
1067 #[test]
1068 fn toml_patch_key_works() {
1069 let dir = setup_project();
1071 let doc_path = dir.path().join("test.md");
1072 std::fs::write(
1073 dir.path().join(".agent-doc/components.toml"),
1074 "[status]\npatch = \"append\"\n",
1075 ).unwrap();
1076 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1077 std::fs::write(&doc_path, doc).unwrap();
1078
1079 let patches = vec![PatchBlock {
1080 name: "status".to_string(),
1081 content: "new\n".to_string(),
1082 attrs: Default::default(),
1083 }];
1084 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1085 assert!(result.contains("old\n"));
1086 assert!(result.contains("new\n"));
1087 }
1088
1089 #[test]
1090 fn stream_override_beats_inline_attr() {
1091 let dir = setup_project();
1093 let doc_path = dir.path().join("test.md");
1094 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1095 std::fs::write(&doc_path, doc).unwrap();
1096
1097 let patches = vec![PatchBlock {
1098 name: "exchange".to_string(),
1099 content: "new\n".to_string(),
1100 attrs: Default::default(),
1101 }];
1102 let mut overrides = std::collections::HashMap::new();
1103 overrides.insert("exchange".to_string(), "replace".to_string());
1104 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1105 assert!(result.contains("new\n"));
1107 assert!(!result.contains("old\n"));
1108 }
1109
1110 #[test]
1111 fn apply_patches_ignores_component_tags_in_code_blocks() {
1112 let dir = setup_project();
1115 let doc_path = dir.path().join("test.md");
1116 let doc = "\
1117# Scaffold Guide
1118
1119Here is an example of a component:
1120
1121```markdown
1122<!-- agent:status -->
1123example scaffold content
1124<!-- /agent:status -->
1125```
1126
1127<!-- agent:status -->
1128real status content
1129<!-- /agent:status -->
1130";
1131 std::fs::write(&doc_path, doc).unwrap();
1132
1133 let patches = vec![PatchBlock {
1134 name: "status".to_string(),
1135 content: "patched status\n".to_string(),
1136 attrs: Default::default(),
1137 }];
1138 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1139
1140 assert!(result.contains("patched status\n"), "real component should receive the patch");
1142 assert!(result.contains("example scaffold content"), "code block content should be preserved");
1144 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1146 }
1147
1148 #[test]
1149 fn unmatched_content_uses_boundary_marker() {
1150 let dir = setup_project();
1151 let file = dir.path().join("test.md");
1152 let doc = concat!(
1153 "---\nagent_doc_format: template\n---\n",
1154 "<!-- agent:exchange patch=append -->\n",
1155 "User prompt here.\n",
1156 "<!-- agent:boundary:test-uuid-123 -->\n",
1157 "<!-- /agent:exchange -->\n",
1158 );
1159 std::fs::write(&file, doc).unwrap();
1160
1161 let patches = vec![];
1163 let unmatched = "### Re: Response\n\nResponse content here.\n";
1164
1165 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1166
1167 let prompt_pos = result.find("User prompt here.").unwrap();
1169 let response_pos = result.find("### Re: Response").unwrap();
1170 assert!(
1171 response_pos > prompt_pos,
1172 "response should appear after the user prompt (boundary insertion)"
1173 );
1174
1175 assert!(
1177 !result.contains("test-uuid-123"),
1178 "boundary marker should be consumed after insertion"
1179 );
1180 }
1181
1182 #[test]
1183 fn explicit_patch_uses_boundary_marker() {
1184 let dir = setup_project();
1185 let file = dir.path().join("test.md");
1186 let doc = concat!(
1187 "---\nagent_doc_format: template\n---\n",
1188 "<!-- agent:exchange patch=append -->\n",
1189 "User prompt here.\n",
1190 "<!-- agent:boundary:patch-uuid-456 -->\n",
1191 "<!-- /agent:exchange -->\n",
1192 );
1193 std::fs::write(&file, doc).unwrap();
1194
1195 let patches = vec![PatchBlock {
1197 name: "exchange".to_string(),
1198 content: "### Re: Response\n\nResponse content.\n".to_string(),
1199 attrs: Default::default(),
1200 }];
1201
1202 let result = apply_patches(doc, &patches, "", &file).unwrap();
1203
1204 let prompt_pos = result.find("User prompt here.").unwrap();
1206 let response_pos = result.find("### Re: Response").unwrap();
1207 assert!(
1208 response_pos > prompt_pos,
1209 "response should appear after user prompt"
1210 );
1211
1212 assert!(
1214 !result.contains("patch-uuid-456"),
1215 "boundary marker should be consumed by explicit patch"
1216 );
1217 }
1218
1219 #[test]
1220 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1221 let dir = setup_project();
1224 let file = dir.path().join("test.md");
1225 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1227 std::fs::write(&file, doc).unwrap();
1228
1229 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1230 let (patches, unmatched) = parse_patches(response).unwrap();
1231 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1232
1233 assert!(
1235 result.contains("<!-- agent:boundary:"),
1236 "boundary must be re-inserted even when original doc had no boundary: {result}"
1237 );
1238 }
1239
1240 #[test]
1241 fn boundary_survives_multiple_cycles() {
1242 let dir = setup_project();
1244 let file = dir.path().join("test.md");
1245 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1246 std::fs::write(&file, doc).unwrap();
1247
1248 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1250 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1251 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1252 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1253
1254 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1256 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1257 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1258 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1259 }
1260
1261 #[test]
1262 fn remove_all_boundaries_skips_code_blocks() {
1263 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1264 let result = remove_all_boundaries(doc);
1265 assert!(
1267 result.contains("<!-- agent:boundary:fake-id -->"),
1268 "boundary inside code block must be preserved: {result}"
1269 );
1270 assert!(
1272 !result.contains("<!-- agent:boundary:real-id -->"),
1273 "boundary outside code block must be removed: {result}"
1274 );
1275 }
1276
1277 #[test]
1278 fn reposition_boundary_moves_to_end() {
1279 let doc = "\
1280<!-- agent:exchange -->
1281Previous response.
1282<!-- agent:boundary:old-id -->
1283User prompt here.
1284<!-- /agent:exchange -->";
1285 let result = reposition_boundary_to_end(doc);
1286 assert!(!result.contains("old-id"), "old boundary should be removed");
1288 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1290 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1292 let prompt_pos = result.find("User prompt here.").unwrap();
1293 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1294 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1295 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1296 }
1297
1298 #[test]
1299 fn reposition_boundary_no_exchange_unchanged() {
1300 let doc = "\
1301<!-- agent:output -->
1302Some content.
1303<!-- /agent:output -->";
1304 let result = reposition_boundary_to_end(doc);
1305 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1306 }
1307
1308 #[test]
1309 fn max_lines_inline_attr_trims_content() {
1310 let dir = setup_project();
1311 let doc_path = dir.path().join("test.md");
1312 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1313 std::fs::write(&doc_path, doc).unwrap();
1314
1315 let patches = vec![PatchBlock {
1316 name: "log".to_string(),
1317 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1318 attrs: Default::default(),
1319 }];
1320 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1321 assert!(!result.contains("line1"));
1322 assert!(!result.contains("line2"));
1323 assert!(result.contains("line3"));
1324 assert!(result.contains("line4"));
1325 assert!(result.contains("line5"));
1326 }
1327
1328 #[test]
1329 fn max_lines_noop_when_under_limit() {
1330 let dir = setup_project();
1331 let doc_path = dir.path().join("test.md");
1332 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1333 std::fs::write(&doc_path, doc).unwrap();
1334
1335 let patches = vec![PatchBlock {
1336 name: "log".to_string(),
1337 content: "line1\nline2\n".to_string(),
1338 attrs: Default::default(),
1339 }];
1340 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1341 assert!(result.contains("line1"));
1342 assert!(result.contains("line2"));
1343 }
1344
1345 #[test]
1346 fn max_lines_from_components_toml() {
1347 let dir = setup_project();
1348 let doc_path = dir.path().join("test.md");
1349 std::fs::write(
1350 dir.path().join(".agent-doc/components.toml"),
1351 "[log]\npatch = \"replace\"\nmax_lines = 2\n",
1352 )
1353 .unwrap();
1354 let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1355 std::fs::write(&doc_path, doc).unwrap();
1356
1357 let patches = vec![PatchBlock {
1358 name: "log".to_string(),
1359 content: "a\nb\nc\nd\n".to_string(),
1360 attrs: Default::default(),
1361 }];
1362 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1363 assert!(!result.contains("\na\n"));
1364 assert!(!result.contains("\nb\n"));
1365 assert!(result.contains("c"));
1366 assert!(result.contains("d"));
1367 }
1368
1369 #[test]
1370 fn max_lines_inline_beats_toml() {
1371 let dir = setup_project();
1372 let doc_path = dir.path().join("test.md");
1373 std::fs::write(
1374 dir.path().join(".agent-doc/components.toml"),
1375 "[log]\nmax_lines = 1\n",
1376 )
1377 .unwrap();
1378 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1379 std::fs::write(&doc_path, doc).unwrap();
1380
1381 let patches = vec![PatchBlock {
1382 name: "log".to_string(),
1383 content: "a\nb\nc\nd\n".to_string(),
1384 attrs: Default::default(),
1385 }];
1386 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1387 assert!(result.contains("b"));
1389 assert!(result.contains("c"));
1390 assert!(result.contains("d"));
1391 }
1392
1393 #[test]
1394 fn parse_patch_with_transfer_source_attr() {
1395 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
1396 let (patches, unmatched) = parse_patches(response).unwrap();
1397 assert_eq!(patches.len(), 1);
1398 assert_eq!(patches[0].name, "exchange");
1399 assert_eq!(patches[0].content, "Transferred content.\n");
1400 assert_eq!(
1401 patches[0].attrs.get("transfer-source"),
1402 Some(&"\"tasks/eval-runner.md\"".to_string())
1403 );
1404 assert!(unmatched.is_empty());
1405 }
1406
1407 #[test]
1408 fn parse_patch_without_attrs() {
1409 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
1410 let (patches, _) = parse_patches(response).unwrap();
1411 assert_eq!(patches.len(), 1);
1412 assert!(patches[0].attrs.is_empty());
1413 }
1414
1415 #[test]
1416 fn parse_patch_with_multiple_attrs() {
1417 let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
1418 let (patches, _) = parse_patches(response).unwrap();
1419 assert_eq!(patches.len(), 1);
1420 assert_eq!(patches[0].name, "output");
1421 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
1422 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
1423 }
1424}