1use anyhow::{Context, Result};
80use serde::Serialize;
81use std::path::Path;
82
83use crate::component::{self, find_comment_end, Component};
84use crate::project_config;
85
86#[derive(Debug, Clone)]
88pub struct PatchBlock {
89 pub name: String,
90 pub content: String,
91 #[allow(dead_code)]
93 pub attrs: std::collections::HashMap<String, String>,
94}
95
96impl PatchBlock {
97 pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
99 PatchBlock {
100 name: name.into(),
101 content: content.into(),
102 attrs: std::collections::HashMap::new(),
103 }
104 }
105}
106
107#[derive(Debug, Serialize)]
109pub struct TemplateInfo {
110 pub template_mode: bool,
111 pub components: Vec<ComponentInfo>,
112}
113
114#[derive(Debug, Serialize)]
116pub struct ComponentInfo {
117 pub name: String,
118 pub mode: String,
119 pub content: String,
120 pub line: usize,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub max_entries: Option<usize>,
123}
124
125#[cfg(test)]
127pub fn is_template_mode(mode: Option<&str>) -> bool {
128 matches!(mode, Some("template"))
129}
130
131pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
136 let bytes = response.as_bytes();
137 let len = bytes.len();
138 let code_ranges = component::find_code_ranges(response);
139 let mut patches = Vec::new();
140 let mut unmatched = String::new();
141 let mut pos = 0;
142 let mut last_end = 0;
143
144 while pos + 4 <= len {
145 if &bytes[pos..pos + 4] != b"<!--" {
146 pos += 1;
147 continue;
148 }
149
150 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
152 pos += 4;
153 continue;
154 }
155
156 let marker_start = pos;
157
158 let close = match find_comment_end(bytes, pos + 4) {
160 Some(c) => c,
161 None => {
162 pos += 4;
163 continue;
164 }
165 };
166
167 let inner = &response[marker_start + 4..close - 3];
168 let trimmed = inner.trim();
169
170 if let Some(rest) = trimmed.strip_prefix("patch:") {
171 let rest = rest.trim();
172 if rest.is_empty() || rest.starts_with('/') {
173 pos = close;
174 continue;
175 }
176
177 let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
179 let name = &rest[..space_idx];
180 let attr_text = rest[space_idx..].trim();
181 (name, component::parse_attrs(attr_text))
182 } else {
183 (rest, std::collections::HashMap::new())
184 };
185
186 let mut content_start = close;
188 if content_start < len && bytes[content_start] == b'\n' {
189 content_start += 1;
190 }
191
192 let before = &response[last_end..marker_start];
194 let trimmed_before = before.trim();
195 if !trimmed_before.is_empty() {
196 if !unmatched.is_empty() {
197 unmatched.push('\n');
198 }
199 unmatched.push_str(trimmed_before);
200 }
201
202 let close_marker = format!("<!-- /patch:{} -->", name);
204 if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
205 let content = &response[content_start..close_pos];
206 patches.push(PatchBlock {
207 name: name.to_string(),
208 content: content.to_string(),
209 attrs,
210 });
211
212 let mut end = close_pos + close_marker.len();
213 if end < len && bytes[end] == b'\n' {
214 end += 1;
215 }
216 last_end = end;
217 pos = end;
218 continue;
219 }
220 }
221
222 pos = close;
223 }
224
225 if last_end < len {
227 let trailing = response[last_end..].trim();
228 if !trailing.is_empty() {
229 if !unmatched.is_empty() {
230 unmatched.push('\n');
231 }
232 unmatched.push_str(trailing);
233 }
234 }
235
236 Ok((patches, unmatched))
237}
238
239pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
248 apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
249}
250
251pub fn apply_patches_with_overrides(
254 doc: &str,
255 patches: &[PatchBlock],
256 unmatched: &str,
257 file: &Path,
258 mode_overrides: &std::collections::HashMap<String, String>,
259) -> Result<String> {
260 let summary = file.file_stem().and_then(|s| s.to_str());
265 let mut result = remove_all_boundaries(doc);
266 if let Ok(components) = component::parse(&result)
267 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
268 {
269 let id = crate::new_boundary_id_with_summary(summary);
270 let marker = crate::format_boundary_marker(&id);
271 let content = exchange.content(&result);
272 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
273 result = exchange.replace_content(&result, &new_content);
274 eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
275 }
276
277 let components = component::parse(&result)
279 .context("failed to parse components")?;
280
281 let configs = load_component_configs(file);
283
284 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
289 let mut overflow = String::new();
290 for patch in patches {
291 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
292 ops.push((idx, patch));
293 } else {
294 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
295 eprintln!(
296 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
297 patch.name,
298 available.join(", ")
299 );
300 if !overflow.is_empty() {
301 overflow.push('\n');
302 }
303 overflow.push_str(&patch.content);
304 }
305 }
306
307 ops.sort_by(|a, b| b.0.cmp(&a.0));
309
310 for (idx, patch) in &ops {
311 let comp = &components[*idx];
312 let mode = mode_overrides.get(&patch.name)
314 .map(|s| s.as_str())
315 .or_else(|| comp.patch_mode())
316 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
317 .unwrap_or_else(|| default_mode(&patch.name));
318 if mode == "append"
320 && let Some(bid) = find_boundary_in_component(&result, comp)
321 {
322 result = comp.append_with_boundary(&result, &patch.content, &bid);
323 continue;
324 }
325 let new_content = apply_mode(mode, comp.content(&result), &patch.content);
326 result = comp.replace_content(&result, &new_content);
327 }
328
329 let mut all_unmatched = String::new();
331 if !overflow.is_empty() {
332 all_unmatched.push_str(&overflow);
333 }
334 if !unmatched.is_empty() {
335 if !all_unmatched.is_empty() {
336 all_unmatched.push('\n');
337 }
338 all_unmatched.push_str(unmatched);
339 }
340
341 if !all_unmatched.is_empty() {
343 let unmatched = &all_unmatched;
344 let components = component::parse(&result)
346 .context("failed to re-parse components after patching")?;
347
348 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
349 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
351 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
352 result = output_comp.append_with_boundary(&result, unmatched, &bid);
353 } else {
354 let existing = output_comp.content(&result);
356 let new_content = if existing.trim().is_empty() {
357 format!("{}\n", unmatched)
358 } else {
359 format!("{}{}\n", existing, unmatched)
360 };
361 result = output_comp.replace_content(&result, &new_content);
362 }
363 } else {
364 if !result.ends_with('\n') {
366 result.push('\n');
367 }
368 result.push_str("\n<!-- agent:exchange -->\n");
369 result.push_str(unmatched);
370 result.push_str("\n<!-- /agent:exchange -->\n");
371 }
372 }
373
374 result = dedup_exchange_adjacent_lines(&result);
377
378 {
384 let max_lines_configs = load_max_lines_configs(file);
385 'stability: for _ in 0..3 {
386 let Ok(components) = component::parse(&result) else { break };
387 for comp in &components {
388 let max_lines = comp
389 .attrs
390 .get("max_lines")
391 .and_then(|s| s.parse::<usize>().ok())
392 .or_else(|| max_lines_configs.get(&comp.name).copied())
393 .unwrap_or(0);
394 if max_lines > 0 {
395 let content = comp.content(&result);
396 let trimmed = limit_lines(content, max_lines);
397 if trimmed.len() != content.len() {
398 let trimmed = format!("{}\n", trimmed.trim_end());
399 result = comp.replace_content(&result, &trimmed);
400 continue 'stability;
402 }
403 }
404 }
405 break; }
407 }
408
409 {
415 if let Ok(components) = component::parse(&result)
416 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
417 && find_boundary_in_component(&result, exchange).is_none()
418 {
419 let id = uuid::Uuid::new_v4().to_string();
421 let marker = format!("<!-- agent:boundary:{} -->", id);
422 let content = exchange.content(&result);
423 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
424 result = exchange.replace_content(&result, &new_content);
425 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
426 }
427 }
428
429 Ok(result)
430}
431
432pub fn reposition_boundary_to_end(doc: &str) -> String {
440 reposition_boundary_to_end_with_summary(doc, None)
441}
442
443pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
448 let mut result = remove_all_boundaries(doc);
449 if let Ok(components) = component::parse(&result)
450 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
451 {
452 let id = crate::new_boundary_id_with_summary(summary);
453 let marker = crate::format_boundary_marker(&id);
454 let content = exchange.content(&result);
455 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
456 result = exchange.replace_content(&result, &new_content);
457 }
458 result
459}
460
461fn remove_all_boundaries(doc: &str) -> String {
464 let prefix = "<!-- agent:boundary:";
465 let suffix = " -->";
466 let code_ranges = component::find_code_ranges(doc);
467 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
468 let mut result = String::with_capacity(doc.len());
469 let mut offset = 0;
470 for line in doc.lines() {
471 let trimmed = line.trim();
472 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
473 if is_boundary && !in_code(offset) {
474 offset += line.len() + 1; continue;
477 }
478 result.push_str(line);
479 result.push('\n');
480 offset += line.len() + 1;
481 }
482 if !doc.ends_with('\n') && result.ends_with('\n') {
483 result.pop();
484 }
485 result
486}
487
488fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
490 let prefix = "<!-- agent:boundary:";
491 let suffix = " -->";
492 let content_region = &doc[comp.open_end..comp.close_start];
493 let code_ranges = component::find_code_ranges(doc);
494 let mut search_from = 0;
495 while let Some(start) = content_region[search_from..].find(prefix) {
496 let abs_start = comp.open_end + search_from + start;
497 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
498 search_from += start + prefix.len();
499 continue;
500 }
501 let after_prefix = &content_region[search_from + start + prefix.len()..];
502 if let Some(end) = after_prefix.find(suffix) {
503 return Some(after_prefix[..end].trim().to_string());
504 }
505 break;
506 }
507 None
508}
509
510pub fn template_info(file: &Path) -> Result<TemplateInfo> {
512 let doc = std::fs::read_to_string(file)
513 .with_context(|| format!("failed to read {}", file.display()))?;
514
515 let (fm, _body) = crate::frontmatter::parse(&doc)?;
516 let template_mode = fm.resolve_mode().is_template();
517
518 let components = component::parse(&doc)
519 .with_context(|| format!("failed to parse components in {}", file.display()))?;
520
521 let configs = load_component_configs(file);
522
523 let component_infos: Vec<ComponentInfo> = components
524 .iter()
525 .map(|comp| {
526 let content = comp.content(&doc).to_string();
527 let mode = comp.patch_mode().map(|s| s.to_string())
529 .or_else(|| configs.get(&comp.name).cloned())
530 .unwrap_or_else(|| default_mode(&comp.name).to_string());
531 let line = doc[..comp.open_start].matches('\n').count() + 1;
533 ComponentInfo {
534 name: comp.name.clone(),
535 mode,
536 content,
537 line,
538 max_entries: None, }
540 })
541 .collect();
542
543 Ok(TemplateInfo {
544 template_mode,
545 components: component_infos,
546 })
547}
548
549fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
553 let proj_cfg = load_project_from_doc(file);
554 proj_cfg
555 .components
556 .iter()
557 .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.patch.clone()))
558 .collect()
559}
560
561fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
564 let proj_cfg = load_project_from_doc(file);
565 proj_cfg
566 .components
567 .iter()
568 .filter(|(_, cfg)| cfg.max_lines > 0)
569 .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.max_lines))
570 .collect()
571}
572
573fn load_project_from_doc(file: &Path) -> project_config::ProjectConfig {
575 let start = file.parent().unwrap_or(file);
576 let mut current = start;
577 loop {
578 let candidate = current.join(".agent-doc").join("config.toml");
579 if candidate.exists() {
580 return project_config::load_project_from(&candidate);
581 }
582 match current.parent() {
583 Some(p) if p != current => current = p,
584 _ => break,
585 }
586 }
587 project_config::load_project()
589}
590
591fn default_mode(name: &str) -> &'static str {
594 match name {
595 "exchange" | "findings" => "append",
596 _ => "replace",
597 }
598}
599
600fn limit_lines(content: &str, max_lines: usize) -> String {
602 let lines: Vec<&str> = content.lines().collect();
603 if lines.len() <= max_lines {
604 return content.to_string();
605 }
606 lines[lines.len() - max_lines..].join("\n")
607}
608
609fn dedup_exchange_adjacent_lines(doc: &str) -> String {
618 let Ok(components) = component::parse(doc) else {
619 return doc.to_string();
620 };
621 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
622 return doc.to_string();
623 };
624 let content = exchange.content(doc);
625 let mut deduped = String::with_capacity(content.len());
626 let mut prev_nonempty: Option<&str> = None;
627 for line in content.lines() {
628 if !line.trim().is_empty() && prev_nonempty == Some(line) {
629 continue;
631 }
632 deduped.push_str(line);
633 deduped.push('\n');
634 if !line.trim().is_empty() {
635 prev_nonempty = Some(line);
636 }
637 }
638 if !content.ends_with('\n') && deduped.ends_with('\n') {
640 deduped.pop();
641 }
642 if deduped == content {
643 return doc.to_string();
644 }
645 exchange.replace_content(doc, &deduped)
646}
647
648fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
650 match mode {
651 "append" => {
652 let stripped = strip_leading_overlap(existing, new_content);
653 format!("{}{}", existing, stripped)
654 }
655 "prepend" => format!("{}{}", new_content, existing),
656 _ => new_content.to_string(), }
658}
659
660fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
665 let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
666 let Some(last) = last_nonempty else {
667 return new_content;
668 };
669 let test = format!("{}\n", last);
670 if new_content.starts_with(test.as_str()) {
671 &new_content[test.len()..]
672 } else {
673 new_content
674 }
675}
676
677#[allow(dead_code)]
678fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
679 let canonical = file.canonicalize().ok()?;
680 let mut dir = canonical.parent()?;
681 loop {
682 if dir.join(".agent-doc").is_dir() {
683 return Some(dir.to_path_buf());
684 }
685 dir = dir.parent()?;
686 }
687}
688
689fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
692 let mut search_start = from;
693 loop {
694 let rel = haystack[search_start..].find(needle)?;
695 let abs = search_start + rel;
696 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
697 search_start = abs + needle.len();
699 continue;
700 }
701 return Some(abs);
702 }
703}
704
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use tempfile::TempDir;
710
711 fn setup_project() -> TempDir {
712 let dir = TempDir::new().unwrap();
713 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
714 dir
715 }
716
717 #[test]
718 fn parse_single_patch() {
719 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
720 let (patches, unmatched) = parse_patches(response).unwrap();
721 assert_eq!(patches.len(), 1);
722 assert_eq!(patches[0].name, "status");
723 assert_eq!(patches[0].content, "Build passing.\n");
724 assert!(unmatched.is_empty());
725 }
726
727 #[test]
728 fn parse_multiple_patches() {
729 let response = "\
730<!-- patch:status -->
731All green.
732<!-- /patch:status -->
733
734<!-- patch:log -->
735- New entry
736<!-- /patch:log -->
737";
738 let (patches, unmatched) = parse_patches(response).unwrap();
739 assert_eq!(patches.len(), 2);
740 assert_eq!(patches[0].name, "status");
741 assert_eq!(patches[0].content, "All green.\n");
742 assert_eq!(patches[1].name, "log");
743 assert_eq!(patches[1].content, "- New entry\n");
744 assert!(unmatched.is_empty());
745 }
746
747 #[test]
748 fn parse_with_unmatched_content() {
749 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
750 let (patches, unmatched) = parse_patches(response).unwrap();
751 assert_eq!(patches.len(), 1);
752 assert_eq!(patches[0].name, "status");
753 assert!(unmatched.contains("Some free text."));
754 assert!(unmatched.contains("Trailing text."));
755 }
756
757 #[test]
758 fn parse_empty_response() {
759 let (patches, unmatched) = parse_patches("").unwrap();
760 assert!(patches.is_empty());
761 assert!(unmatched.is_empty());
762 }
763
764 #[test]
765 fn parse_no_patches() {
766 let response = "Just a plain response with no patch blocks.";
767 let (patches, unmatched) = parse_patches(response).unwrap();
768 assert!(patches.is_empty());
769 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
770 }
771
772 #[test]
773 fn apply_patches_replace() {
774 let dir = setup_project();
775 let doc_path = dir.path().join("test.md");
776 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
777 std::fs::write(&doc_path, doc).unwrap();
778
779 let patches = vec![PatchBlock {
780 name: "status".to_string(),
781 content: "new\n".to_string(),
782 attrs: Default::default(),
783 }];
784 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
785 assert!(result.contains("new\n"));
786 assert!(!result.contains("\nold\n"));
787 assert!(result.contains("<!-- agent:status -->"));
788 }
789
790 #[test]
791 fn apply_patches_unmatched_creates_exchange() {
792 let dir = setup_project();
793 let doc_path = dir.path().join("test.md");
794 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
795 std::fs::write(&doc_path, doc).unwrap();
796
797 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
798 assert!(result.contains("<!-- agent:exchange -->"));
799 assert!(result.contains("Extra info here"));
800 assert!(result.contains("<!-- /agent:exchange -->"));
801 }
802
803 #[test]
804 fn apply_patches_unmatched_appends_to_existing_exchange() {
805 let dir = setup_project();
806 let doc_path = dir.path().join("test.md");
807 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
808 std::fs::write(&doc_path, doc).unwrap();
809
810 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
811 assert!(result.contains("previous"));
812 assert!(result.contains("new stuff"));
813 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
815 }
816
817 #[test]
818 fn apply_patches_missing_component_routes_to_exchange() {
819 let dir = setup_project();
820 let doc_path = dir.path().join("test.md");
821 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
822 std::fs::write(&doc_path, doc).unwrap();
823
824 let patches = vec![PatchBlock {
825 name: "nonexistent".to_string(),
826 content: "overflow data\n".to_string(),
827 attrs: Default::default(),
828 }];
829 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
830 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
832 assert!(result.contains("previous"), "existing exchange content should be preserved");
833 }
834
835 #[test]
836 fn apply_patches_missing_component_creates_exchange() {
837 let dir = setup_project();
838 let doc_path = dir.path().join("test.md");
839 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
840 std::fs::write(&doc_path, doc).unwrap();
841
842 let patches = vec![PatchBlock {
843 name: "nonexistent".to_string(),
844 content: "overflow data\n".to_string(),
845 attrs: Default::default(),
846 }];
847 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
848 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
850 assert!(result.contains("overflow data"), "overflow content should be in exchange");
851 }
852
853 #[test]
854 fn is_template_mode_detection() {
855 assert!(is_template_mode(Some("template")));
856 assert!(!is_template_mode(Some("append")));
857 assert!(!is_template_mode(None));
858 }
859
860 #[test]
861 fn template_info_works() {
862 let dir = setup_project();
863 let doc_path = dir.path().join("test.md");
864 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
865 std::fs::write(&doc_path, doc).unwrap();
866
867 let info = template_info(&doc_path).unwrap();
868 assert!(info.template_mode);
869 assert_eq!(info.components.len(), 1);
870 assert_eq!(info.components[0].name, "status");
871 assert_eq!(info.components[0].content, "content\n");
872 }
873
874 #[test]
875 fn template_info_legacy_mode_works() {
876 let dir = setup_project();
877 let doc_path = dir.path().join("test.md");
878 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
879 std::fs::write(&doc_path, doc).unwrap();
880
881 let info = template_info(&doc_path).unwrap();
882 assert!(info.template_mode);
883 }
884
885 #[test]
886 fn template_info_append_mode() {
887 let dir = setup_project();
888 let doc_path = dir.path().join("test.md");
889 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
890 std::fs::write(&doc_path, doc).unwrap();
891
892 let info = template_info(&doc_path).unwrap();
893 assert!(!info.template_mode);
894 assert!(info.components.is_empty());
895 }
896
897 #[test]
898 fn parse_patches_ignores_markers_in_fenced_code_block() {
899 let response = "\
900<!-- patch:exchange -->
901Here is how you use component markers:
902
903```markdown
904<!-- agent:exchange -->
905example content
906<!-- /agent:exchange -->
907```
908
909<!-- /patch:exchange -->
910";
911 let (patches, unmatched) = parse_patches(response).unwrap();
912 assert_eq!(patches.len(), 1);
913 assert_eq!(patches[0].name, "exchange");
914 assert!(patches[0].content.contains("```markdown"));
915 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
916 assert!(unmatched.is_empty());
917 }
918
919 #[test]
920 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
921 let response = "\
923<!-- patch:exchange -->
924Real content here.
925
926```markdown
927<!-- patch:fake -->
928This is just an example.
929<!-- /patch:fake -->
930```
931
932<!-- /patch:exchange -->
933";
934 let (patches, unmatched) = parse_patches(response).unwrap();
935 assert_eq!(patches.len(), 1, "should only find the outer real patch");
936 assert_eq!(patches[0].name, "exchange");
937 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
938 assert!(unmatched.is_empty());
939 }
940
941 #[test]
942 fn parse_patches_ignores_markers_in_tilde_fence() {
943 let response = "\
944<!-- patch:status -->
945OK
946<!-- /patch:status -->
947
948~~~
949<!-- patch:fake -->
950example
951<!-- /patch:fake -->
952~~~
953";
954 let (patches, _unmatched) = parse_patches(response).unwrap();
955 assert_eq!(patches.len(), 1);
957 assert_eq!(patches[0].name, "status");
958 }
959
960 #[test]
961 fn parse_patches_ignores_closing_marker_in_code_block() {
962 let response = "\
965<!-- patch:exchange -->
966Example:
967
968```
969<!-- /patch:exchange -->
970```
971
972Real content continues.
973<!-- /patch:exchange -->
974";
975 let (patches, _unmatched) = parse_patches(response).unwrap();
976 assert_eq!(patches.len(), 1);
977 assert_eq!(patches[0].name, "exchange");
978 assert!(patches[0].content.contains("Real content continues."));
979 }
980
981 #[test]
982 fn parse_patches_normal_markers_still_work() {
983 let response = "\
985<!-- patch:status -->
986All systems go.
987<!-- /patch:status -->
988<!-- patch:log -->
989- Entry 1
990<!-- /patch:log -->
991";
992 let (patches, unmatched) = parse_patches(response).unwrap();
993 assert_eq!(patches.len(), 2);
994 assert_eq!(patches[0].name, "status");
995 assert_eq!(patches[0].content, "All systems go.\n");
996 assert_eq!(patches[1].name, "log");
997 assert_eq!(patches[1].content, "- Entry 1\n");
998 assert!(unmatched.is_empty());
999 }
1000
1001 #[test]
1004 fn inline_attr_mode_overrides_config() {
1005 let dir = setup_project();
1007 let doc_path = dir.path().join("test.md");
1008 std::fs::write(
1010 dir.path().join(".agent-doc/config.toml"),
1011 "[components.status]\npatch = \"append\"\n",
1012 ).unwrap();
1013 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
1015 std::fs::write(&doc_path, doc).unwrap();
1016
1017 let patches = vec![PatchBlock {
1018 name: "status".to_string(),
1019 content: "new\n".to_string(),
1020 attrs: Default::default(),
1021 }];
1022 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1023 assert!(result.contains("new\n"));
1025 assert!(!result.contains("old\n"));
1026 }
1027
1028 #[test]
1029 fn inline_attr_mode_overrides_default() {
1030 let dir = setup_project();
1032 let doc_path = dir.path().join("test.md");
1033 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
1034 std::fs::write(&doc_path, doc).unwrap();
1035
1036 let patches = vec![PatchBlock {
1037 name: "exchange".to_string(),
1038 content: "new\n".to_string(),
1039 attrs: Default::default(),
1040 }];
1041 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1042 assert!(result.contains("new\n"));
1043 assert!(!result.contains("old\n"));
1044 }
1045
1046 #[test]
1047 fn no_inline_attr_falls_back_to_config() {
1048 let dir = setup_project();
1050 let doc_path = dir.path().join("test.md");
1051 std::fs::write(
1052 dir.path().join(".agent-doc/config.toml"),
1053 "[components.status]\npatch = \"append\"\n",
1054 ).unwrap();
1055 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1056 std::fs::write(&doc_path, doc).unwrap();
1057
1058 let patches = vec![PatchBlock {
1059 name: "status".to_string(),
1060 content: "new\n".to_string(),
1061 attrs: Default::default(),
1062 }];
1063 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1064 assert!(result.contains("old\n"));
1066 assert!(result.contains("new\n"));
1067 }
1068
1069 #[test]
1070 fn no_inline_attr_no_config_falls_back_to_default() {
1071 let dir = setup_project();
1073 let doc_path = dir.path().join("test.md");
1074 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1075 std::fs::write(&doc_path, doc).unwrap();
1076
1077 let patches = vec![PatchBlock {
1078 name: "exchange".to_string(),
1079 content: "new\n".to_string(),
1080 attrs: Default::default(),
1081 }];
1082 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1083 assert!(result.contains("old\n"));
1085 assert!(result.contains("new\n"));
1086 }
1087
1088 #[test]
1089 fn inline_patch_attr_overrides_config() {
1090 let dir = setup_project();
1092 let doc_path = dir.path().join("test.md");
1093 std::fs::write(
1094 dir.path().join(".agent-doc/config.toml"),
1095 "[components.status]\npatch = \"append\"\n",
1096 ).unwrap();
1097 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1098 std::fs::write(&doc_path, doc).unwrap();
1099
1100 let patches = vec![PatchBlock {
1101 name: "status".to_string(),
1102 content: "new\n".to_string(),
1103 attrs: Default::default(),
1104 }];
1105 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1106 assert!(result.contains("new\n"));
1107 assert!(!result.contains("old\n"));
1108 }
1109
1110 #[test]
1111 fn inline_patch_attr_overrides_mode_attr() {
1112 let dir = setup_project();
1114 let doc_path = dir.path().join("test.md");
1115 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1116 std::fs::write(&doc_path, doc).unwrap();
1117
1118 let patches = vec![PatchBlock {
1119 name: "exchange".to_string(),
1120 content: "new\n".to_string(),
1121 attrs: Default::default(),
1122 }];
1123 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1124 assert!(result.contains("new\n"));
1125 assert!(!result.contains("old\n"));
1126 }
1127
1128 #[test]
1129 fn toml_patch_key_works() {
1130 let dir = setup_project();
1132 let doc_path = dir.path().join("test.md");
1133 std::fs::write(
1134 dir.path().join(".agent-doc/config.toml"),
1135 "[components.status]\npatch = \"append\"\n",
1136 ).unwrap();
1137 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1138 std::fs::write(&doc_path, doc).unwrap();
1139
1140 let patches = vec![PatchBlock {
1141 name: "status".to_string(),
1142 content: "new\n".to_string(),
1143 attrs: Default::default(),
1144 }];
1145 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1146 assert!(result.contains("old\n"));
1147 assert!(result.contains("new\n"));
1148 }
1149
1150 #[test]
1151 fn stream_override_beats_inline_attr() {
1152 let dir = setup_project();
1154 let doc_path = dir.path().join("test.md");
1155 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1156 std::fs::write(&doc_path, doc).unwrap();
1157
1158 let patches = vec![PatchBlock {
1159 name: "exchange".to_string(),
1160 content: "new\n".to_string(),
1161 attrs: Default::default(),
1162 }];
1163 let mut overrides = std::collections::HashMap::new();
1164 overrides.insert("exchange".to_string(), "replace".to_string());
1165 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1166 assert!(result.contains("new\n"));
1168 assert!(!result.contains("old\n"));
1169 }
1170
1171 #[test]
1172 fn apply_patches_ignores_component_tags_in_code_blocks() {
1173 let dir = setup_project();
1176 let doc_path = dir.path().join("test.md");
1177 let doc = "\
1178# Scaffold Guide
1179
1180Here is an example of a component:
1181
1182```markdown
1183<!-- agent:status -->
1184example scaffold content
1185<!-- /agent:status -->
1186```
1187
1188<!-- agent:status -->
1189real status content
1190<!-- /agent:status -->
1191";
1192 std::fs::write(&doc_path, doc).unwrap();
1193
1194 let patches = vec![PatchBlock {
1195 name: "status".to_string(),
1196 content: "patched status\n".to_string(),
1197 attrs: Default::default(),
1198 }];
1199 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1200
1201 assert!(result.contains("patched status\n"), "real component should receive the patch");
1203 assert!(result.contains("example scaffold content"), "code block content should be preserved");
1205 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1207 }
1208
1209 #[test]
1210 fn unmatched_content_uses_boundary_marker() {
1211 let dir = setup_project();
1212 let file = dir.path().join("test.md");
1213 let doc = concat!(
1214 "---\nagent_doc_format: template\n---\n",
1215 "<!-- agent:exchange patch=append -->\n",
1216 "User prompt here.\n",
1217 "<!-- agent:boundary:test-uuid-123 -->\n",
1218 "<!-- /agent:exchange -->\n",
1219 );
1220 std::fs::write(&file, doc).unwrap();
1221
1222 let patches = vec![];
1224 let unmatched = "### Re: Response\n\nResponse content here.\n";
1225
1226 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1227
1228 let prompt_pos = result.find("User prompt here.").unwrap();
1230 let response_pos = result.find("### Re: Response").unwrap();
1231 assert!(
1232 response_pos > prompt_pos,
1233 "response should appear after the user prompt (boundary insertion)"
1234 );
1235
1236 assert!(
1238 !result.contains("test-uuid-123"),
1239 "boundary marker should be consumed after insertion"
1240 );
1241 }
1242
1243 #[test]
1244 fn explicit_patch_uses_boundary_marker() {
1245 let dir = setup_project();
1246 let file = dir.path().join("test.md");
1247 let doc = concat!(
1248 "---\nagent_doc_format: template\n---\n",
1249 "<!-- agent:exchange patch=append -->\n",
1250 "User prompt here.\n",
1251 "<!-- agent:boundary:patch-uuid-456 -->\n",
1252 "<!-- /agent:exchange -->\n",
1253 );
1254 std::fs::write(&file, doc).unwrap();
1255
1256 let patches = vec![PatchBlock {
1258 name: "exchange".to_string(),
1259 content: "### Re: Response\n\nResponse content.\n".to_string(),
1260 attrs: Default::default(),
1261 }];
1262
1263 let result = apply_patches(doc, &patches, "", &file).unwrap();
1264
1265 let prompt_pos = result.find("User prompt here.").unwrap();
1267 let response_pos = result.find("### Re: Response").unwrap();
1268 assert!(
1269 response_pos > prompt_pos,
1270 "response should appear after user prompt"
1271 );
1272
1273 assert!(
1275 !result.contains("patch-uuid-456"),
1276 "boundary marker should be consumed by explicit patch"
1277 );
1278 }
1279
1280 #[test]
1281 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1282 let dir = setup_project();
1285 let file = dir.path().join("test.md");
1286 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1288 std::fs::write(&file, doc).unwrap();
1289
1290 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1291 let (patches, unmatched) = parse_patches(response).unwrap();
1292 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1293
1294 assert!(
1296 result.contains("<!-- agent:boundary:"),
1297 "boundary must be re-inserted even when original doc had no boundary: {result}"
1298 );
1299 }
1300
1301 #[test]
1302 fn boundary_survives_multiple_cycles() {
1303 let dir = setup_project();
1305 let file = dir.path().join("test.md");
1306 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1307 std::fs::write(&file, doc).unwrap();
1308
1309 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1311 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1312 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1313 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1314
1315 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1317 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1318 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1319 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1320 }
1321
1322 #[test]
1323 fn remove_all_boundaries_skips_code_blocks() {
1324 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1325 let result = remove_all_boundaries(doc);
1326 assert!(
1328 result.contains("<!-- agent:boundary:fake-id -->"),
1329 "boundary inside code block must be preserved: {result}"
1330 );
1331 assert!(
1333 !result.contains("<!-- agent:boundary:real-id -->"),
1334 "boundary outside code block must be removed: {result}"
1335 );
1336 }
1337
1338 #[test]
1339 fn reposition_boundary_moves_to_end() {
1340 let doc = "\
1341<!-- agent:exchange -->
1342Previous response.
1343<!-- agent:boundary:old-id -->
1344User prompt here.
1345<!-- /agent:exchange -->";
1346 let result = reposition_boundary_to_end(doc);
1347 assert!(!result.contains("old-id"), "old boundary should be removed");
1349 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1351 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1353 let prompt_pos = result.find("User prompt here.").unwrap();
1354 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1355 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1356 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1357 }
1358
1359 #[test]
1360 fn reposition_boundary_no_exchange_unchanged() {
1361 let doc = "\
1362<!-- agent:output -->
1363Some content.
1364<!-- /agent:output -->";
1365 let result = reposition_boundary_to_end(doc);
1366 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1367 }
1368
1369 #[test]
1370 fn max_lines_inline_attr_trims_content() {
1371 let dir = setup_project();
1372 let doc_path = dir.path().join("test.md");
1373 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1374 std::fs::write(&doc_path, doc).unwrap();
1375
1376 let patches = vec![PatchBlock {
1377 name: "log".to_string(),
1378 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1379 attrs: Default::default(),
1380 }];
1381 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1382 assert!(!result.contains("line1"));
1383 assert!(!result.contains("line2"));
1384 assert!(result.contains("line3"));
1385 assert!(result.contains("line4"));
1386 assert!(result.contains("line5"));
1387 }
1388
1389 #[test]
1390 fn max_lines_noop_when_under_limit() {
1391 let dir = setup_project();
1392 let doc_path = dir.path().join("test.md");
1393 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1394 std::fs::write(&doc_path, doc).unwrap();
1395
1396 let patches = vec![PatchBlock {
1397 name: "log".to_string(),
1398 content: "line1\nline2\n".to_string(),
1399 attrs: Default::default(),
1400 }];
1401 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1402 assert!(result.contains("line1"));
1403 assert!(result.contains("line2"));
1404 }
1405
1406 #[test]
1407 fn max_lines_from_components_toml() {
1408 let dir = setup_project();
1409 let doc_path = dir.path().join("test.md");
1410 std::fs::write(
1411 dir.path().join(".agent-doc/config.toml"),
1412 "[components.log]\npatch = \"replace\"\nmax_lines = 2\n",
1413 )
1414 .unwrap();
1415 let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1416 std::fs::write(&doc_path, doc).unwrap();
1417
1418 let patches = vec![PatchBlock {
1419 name: "log".to_string(),
1420 content: "a\nb\nc\nd\n".to_string(),
1421 attrs: Default::default(),
1422 }];
1423 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1424 assert!(!result.contains("\na\n"));
1425 assert!(!result.contains("\nb\n"));
1426 assert!(result.contains("c"));
1427 assert!(result.contains("d"));
1428 }
1429
1430 #[test]
1431 fn max_lines_inline_beats_toml() {
1432 let dir = setup_project();
1433 let doc_path = dir.path().join("test.md");
1434 std::fs::write(
1435 dir.path().join(".agent-doc/config.toml"),
1436 "[components.log]\nmax_lines = 1\n",
1437 )
1438 .unwrap();
1439 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1440 std::fs::write(&doc_path, doc).unwrap();
1441
1442 let patches = vec![PatchBlock {
1443 name: "log".to_string(),
1444 content: "a\nb\nc\nd\n".to_string(),
1445 attrs: Default::default(),
1446 }];
1447 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1448 assert!(result.contains("b"));
1450 assert!(result.contains("c"));
1451 assert!(result.contains("d"));
1452 }
1453
1454 #[test]
1455 fn parse_patch_with_transfer_source_attr() {
1456 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
1457 let (patches, unmatched) = parse_patches(response).unwrap();
1458 assert_eq!(patches.len(), 1);
1459 assert_eq!(patches[0].name, "exchange");
1460 assert_eq!(patches[0].content, "Transferred content.\n");
1461 assert_eq!(
1462 patches[0].attrs.get("transfer-source"),
1463 Some(&"\"tasks/eval-runner.md\"".to_string())
1464 );
1465 assert!(unmatched.is_empty());
1466 }
1467
1468 #[test]
1469 fn parse_patch_without_attrs() {
1470 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
1471 let (patches, _) = parse_patches(response).unwrap();
1472 assert_eq!(patches.len(), 1);
1473 assert!(patches[0].attrs.is_empty());
1474 }
1475
1476 #[test]
1477 fn parse_patch_with_multiple_attrs() {
1478 let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
1479 let (patches, _) = parse_patches(response).unwrap();
1480 assert_eq!(patches.len(), 1);
1481 assert_eq!(patches[0].name, "output");
1482 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
1483 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
1484 }
1485
1486 #[test]
1487 fn apply_patches_dedup_exchange_adjacent_echo() {
1488 let dir = setup_project();
1492 let doc_path = dir.path().join("test.md");
1493 let doc = "\
1494<!-- agent:exchange patch=append -->
1495❯ How do I configure .mise.toml?
1496<!-- /agent:exchange -->
1497";
1498 std::fs::write(&doc_path, doc).unwrap();
1499
1500 let patches = vec![PatchBlock {
1502 name: "exchange".to_string(),
1503 content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
1504 attrs: Default::default(),
1505 }];
1506 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1507
1508 let count = result.matches("❯ How do I configure .mise.toml?").count();
1509 assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
1510 assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
1511 assert!(result.contains("Use `[env]` section."), "response body should be present");
1512 }
1513
1514 #[test]
1515 fn apply_patches_dedup_preserves_blank_lines() {
1516 let dir = setup_project();
1518 let doc_path = dir.path().join("test.md");
1519 let doc = "\
1520<!-- agent:exchange patch=append -->
1521Previous response.
1522<!-- /agent:exchange -->
1523";
1524 std::fs::write(&doc_path, doc).unwrap();
1525
1526 let patches = vec![PatchBlock {
1527 name: "exchange".to_string(),
1528 content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
1529 attrs: Default::default(),
1530 }];
1531 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1532 assert!(result.contains("Previous response."), "existing content preserved");
1533 assert!(result.contains("### Re: something"), "response heading present");
1534 assert!(result.contains('\n'), "blank lines preserved");
1536 }
1537
1538 #[test]
1539 fn apply_mode_append_strips_leading_overlap() {
1540 let existing = "❯ How do I configure .mise.toml?\n";
1543 let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
1544 let result = apply_mode("append", existing, new_content);
1545 let count = result.matches("❯ How do I configure .mise.toml?").count();
1546 assert_eq!(count, 1, "overlap line should appear exactly once");
1547 assert!(result.contains("### Re: configure"));
1548 }
1549
1550 #[test]
1551 fn apply_mode_append_no_overlap_unchanged() {
1552 let existing = "Previous content.\n";
1555 let new_content = "### Re: something\n\nAnswer.\n";
1556 let result = apply_mode("append", existing, new_content);
1557 assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
1558 }
1559}