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 result = dedup_exchange_adjacent_lines(&result);
376
377 {
383 let max_lines_configs = load_max_lines_configs(file);
384 'stability: for _ in 0..3 {
385 let Ok(components) = component::parse(&result) else { break };
386 for comp in &components {
387 let max_lines = comp
388 .attrs
389 .get("max_lines")
390 .and_then(|s| s.parse::<usize>().ok())
391 .or_else(|| max_lines_configs.get(&comp.name).copied())
392 .unwrap_or(0);
393 if max_lines > 0 {
394 let content = comp.content(&result);
395 let trimmed = limit_lines(content, max_lines);
396 if trimmed.len() != content.len() {
397 let trimmed = format!("{}\n", trimmed.trim_end());
398 result = comp.replace_content(&result, &trimmed);
399 continue 'stability;
401 }
402 }
403 }
404 break; }
406 }
407
408 {
414 if let Ok(components) = component::parse(&result)
415 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
416 && find_boundary_in_component(&result, exchange).is_none()
417 {
418 let id = uuid::Uuid::new_v4().to_string();
420 let marker = format!("<!-- agent:boundary:{} -->", id);
421 let content = exchange.content(&result);
422 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
423 result = exchange.replace_content(&result, &new_content);
424 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
425 }
426 }
427
428 Ok(result)
429}
430
431pub fn reposition_boundary_to_end(doc: &str) -> String {
439 reposition_boundary_to_end_with_summary(doc, None)
440}
441
442pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
447 let mut result = remove_all_boundaries(doc);
448 if let Ok(components) = component::parse(&result)
449 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
450 {
451 let id = crate::new_boundary_id_with_summary(summary);
452 let marker = crate::format_boundary_marker(&id);
453 let content = exchange.content(&result);
454 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
455 result = exchange.replace_content(&result, &new_content);
456 }
457 result
458}
459
460fn remove_all_boundaries(doc: &str) -> String {
463 let prefix = "<!-- agent:boundary:";
464 let suffix = " -->";
465 let code_ranges = component::find_code_ranges(doc);
466 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
467 let mut result = String::with_capacity(doc.len());
468 let mut offset = 0;
469 for line in doc.lines() {
470 let trimmed = line.trim();
471 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
472 if is_boundary && !in_code(offset) {
473 offset += line.len() + 1; continue;
476 }
477 result.push_str(line);
478 result.push('\n');
479 offset += line.len() + 1;
480 }
481 if !doc.ends_with('\n') && result.ends_with('\n') {
482 result.pop();
483 }
484 result
485}
486
487fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
489 let prefix = "<!-- agent:boundary:";
490 let suffix = " -->";
491 let content_region = &doc[comp.open_end..comp.close_start];
492 let code_ranges = component::find_code_ranges(doc);
493 let mut search_from = 0;
494 while let Some(start) = content_region[search_from..].find(prefix) {
495 let abs_start = comp.open_end + search_from + start;
496 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
497 search_from += start + prefix.len();
498 continue;
499 }
500 let after_prefix = &content_region[search_from + start + prefix.len()..];
501 if let Some(end) = after_prefix.find(suffix) {
502 return Some(after_prefix[..end].trim().to_string());
503 }
504 break;
505 }
506 None
507}
508
509pub fn template_info(file: &Path) -> Result<TemplateInfo> {
511 let doc = std::fs::read_to_string(file)
512 .with_context(|| format!("failed to read {}", file.display()))?;
513
514 let (fm, _body) = crate::frontmatter::parse(&doc)?;
515 let template_mode = fm.resolve_mode().is_template();
516
517 let components = component::parse(&doc)
518 .with_context(|| format!("failed to parse components in {}", file.display()))?;
519
520 let configs = load_component_configs(file);
521
522 let component_infos: Vec<ComponentInfo> = components
523 .iter()
524 .map(|comp| {
525 let content = comp.content(&doc).to_string();
526 let mode = comp.patch_mode().map(|s| s.to_string())
528 .or_else(|| configs.get(&comp.name).cloned())
529 .unwrap_or_else(|| default_mode(&comp.name).to_string());
530 let line = doc[..comp.open_start].matches('\n').count() + 1;
532 ComponentInfo {
533 name: comp.name.clone(),
534 mode,
535 content,
536 line,
537 max_entries: None, }
539 })
540 .collect();
541
542 Ok(TemplateInfo {
543 template_mode,
544 components: component_infos,
545 })
546}
547
548fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
551 let mut result = std::collections::HashMap::new();
552 let root = find_project_root(file);
553 if let Some(root) = root {
554 let config_path = root.join(".agent-doc/components.toml");
555 if config_path.exists()
556 && let Ok(content) = std::fs::read_to_string(&config_path)
557 && let Ok(table) = content.parse::<toml::Table>()
558 {
559 for (name, value) in &table {
560 if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
562 .or_else(|| value.get("mode").and_then(|v| v.as_str()))
563 {
564 result.insert(name.clone(), mode.to_string());
565 }
566 }
567 }
568 }
569 result
570}
571
572fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
574 let mut result = std::collections::HashMap::new();
575 let root = find_project_root(file);
576 if let Some(root) = root {
577 let config_path = root.join(".agent-doc/components.toml");
578 if config_path.exists()
579 && let Ok(content) = std::fs::read_to_string(&config_path)
580 && let Ok(table) = content.parse::<toml::Table>()
581 {
582 for (name, value) in &table {
583 if let Some(max_lines) = value.get("max_lines").and_then(|v| v.as_integer())
584 && max_lines > 0
585 {
586 result.insert(name.clone(), max_lines as usize);
587 }
588 }
589 }
590 }
591 result
592}
593
594fn default_mode(name: &str) -> &'static str {
597 match name {
598 "exchange" | "findings" => "append",
599 _ => "replace",
600 }
601}
602
603fn limit_lines(content: &str, max_lines: usize) -> String {
605 let lines: Vec<&str> = content.lines().collect();
606 if lines.len() <= max_lines {
607 return content.to_string();
608 }
609 lines[lines.len() - max_lines..].join("\n")
610}
611
612fn dedup_exchange_adjacent_lines(doc: &str) -> String {
621 let Ok(components) = component::parse(doc) else {
622 return doc.to_string();
623 };
624 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
625 return doc.to_string();
626 };
627 let content = exchange.content(doc);
628 let mut deduped = String::with_capacity(content.len());
629 let mut prev_nonempty: Option<&str> = None;
630 for line in content.lines() {
631 if !line.trim().is_empty() && prev_nonempty == Some(line) {
632 continue;
634 }
635 deduped.push_str(line);
636 deduped.push('\n');
637 if !line.trim().is_empty() {
638 prev_nonempty = Some(line);
639 }
640 }
641 if !content.ends_with('\n') && deduped.ends_with('\n') {
643 deduped.pop();
644 }
645 if deduped == content {
646 return doc.to_string();
647 }
648 exchange.replace_content(doc, &deduped)
649}
650
651fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
653 match mode {
654 "append" => {
655 let stripped = strip_leading_overlap(existing, new_content);
656 format!("{}{}", existing, stripped)
657 }
658 "prepend" => format!("{}{}", new_content, existing),
659 _ => new_content.to_string(), }
661}
662
663fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
668 let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
669 let Some(last) = last_nonempty else {
670 return new_content;
671 };
672 let test = format!("{}\n", last);
673 if new_content.starts_with(test.as_str()) {
674 &new_content[test.len()..]
675 } else {
676 new_content
677 }
678}
679
680fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
681 let canonical = file.canonicalize().ok()?;
682 let mut dir = canonical.parent()?;
683 loop {
684 if dir.join(".agent-doc").is_dir() {
685 return Some(dir.to_path_buf());
686 }
687 dir = dir.parent()?;
688 }
689}
690
691fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
694 let mut search_start = from;
695 loop {
696 let rel = haystack[search_start..].find(needle)?;
697 let abs = search_start + rel;
698 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
699 search_start = abs + needle.len();
701 continue;
702 }
703 return Some(abs);
704 }
705}
706
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use tempfile::TempDir;
712
713 fn setup_project() -> TempDir {
714 let dir = TempDir::new().unwrap();
715 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
716 dir
717 }
718
719 #[test]
720 fn parse_single_patch() {
721 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
722 let (patches, unmatched) = parse_patches(response).unwrap();
723 assert_eq!(patches.len(), 1);
724 assert_eq!(patches[0].name, "status");
725 assert_eq!(patches[0].content, "Build passing.\n");
726 assert!(unmatched.is_empty());
727 }
728
729 #[test]
730 fn parse_multiple_patches() {
731 let response = "\
732<!-- patch:status -->
733All green.
734<!-- /patch:status -->
735
736<!-- patch:log -->
737- New entry
738<!-- /patch:log -->
739";
740 let (patches, unmatched) = parse_patches(response).unwrap();
741 assert_eq!(patches.len(), 2);
742 assert_eq!(patches[0].name, "status");
743 assert_eq!(patches[0].content, "All green.\n");
744 assert_eq!(patches[1].name, "log");
745 assert_eq!(patches[1].content, "- New entry\n");
746 assert!(unmatched.is_empty());
747 }
748
749 #[test]
750 fn parse_with_unmatched_content() {
751 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
752 let (patches, unmatched) = parse_patches(response).unwrap();
753 assert_eq!(patches.len(), 1);
754 assert_eq!(patches[0].name, "status");
755 assert!(unmatched.contains("Some free text."));
756 assert!(unmatched.contains("Trailing text."));
757 }
758
759 #[test]
760 fn parse_empty_response() {
761 let (patches, unmatched) = parse_patches("").unwrap();
762 assert!(patches.is_empty());
763 assert!(unmatched.is_empty());
764 }
765
766 #[test]
767 fn parse_no_patches() {
768 let response = "Just a plain response with no patch blocks.";
769 let (patches, unmatched) = parse_patches(response).unwrap();
770 assert!(patches.is_empty());
771 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
772 }
773
774 #[test]
775 fn apply_patches_replace() {
776 let dir = setup_project();
777 let doc_path = dir.path().join("test.md");
778 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
779 std::fs::write(&doc_path, doc).unwrap();
780
781 let patches = vec![PatchBlock {
782 name: "status".to_string(),
783 content: "new\n".to_string(),
784 attrs: Default::default(),
785 }];
786 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
787 assert!(result.contains("new\n"));
788 assert!(!result.contains("\nold\n"));
789 assert!(result.contains("<!-- agent:status -->"));
790 }
791
792 #[test]
793 fn apply_patches_unmatched_creates_exchange() {
794 let dir = setup_project();
795 let doc_path = dir.path().join("test.md");
796 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
797 std::fs::write(&doc_path, doc).unwrap();
798
799 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
800 assert!(result.contains("<!-- agent:exchange -->"));
801 assert!(result.contains("Extra info here"));
802 assert!(result.contains("<!-- /agent:exchange -->"));
803 }
804
805 #[test]
806 fn apply_patches_unmatched_appends_to_existing_exchange() {
807 let dir = setup_project();
808 let doc_path = dir.path().join("test.md");
809 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
810 std::fs::write(&doc_path, doc).unwrap();
811
812 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
813 assert!(result.contains("previous"));
814 assert!(result.contains("new stuff"));
815 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
817 }
818
819 #[test]
820 fn apply_patches_missing_component_routes_to_exchange() {
821 let dir = setup_project();
822 let doc_path = dir.path().join("test.md");
823 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
824 std::fs::write(&doc_path, doc).unwrap();
825
826 let patches = vec![PatchBlock {
827 name: "nonexistent".to_string(),
828 content: "overflow data\n".to_string(),
829 attrs: Default::default(),
830 }];
831 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
832 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
834 assert!(result.contains("previous"), "existing exchange content should be preserved");
835 }
836
837 #[test]
838 fn apply_patches_missing_component_creates_exchange() {
839 let dir = setup_project();
840 let doc_path = dir.path().join("test.md");
841 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
842 std::fs::write(&doc_path, doc).unwrap();
843
844 let patches = vec![PatchBlock {
845 name: "nonexistent".to_string(),
846 content: "overflow data\n".to_string(),
847 attrs: Default::default(),
848 }];
849 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
850 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
852 assert!(result.contains("overflow data"), "overflow content should be in exchange");
853 }
854
855 #[test]
856 fn is_template_mode_detection() {
857 assert!(is_template_mode(Some("template")));
858 assert!(!is_template_mode(Some("append")));
859 assert!(!is_template_mode(None));
860 }
861
862 #[test]
863 fn template_info_works() {
864 let dir = setup_project();
865 let doc_path = dir.path().join("test.md");
866 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
867 std::fs::write(&doc_path, doc).unwrap();
868
869 let info = template_info(&doc_path).unwrap();
870 assert!(info.template_mode);
871 assert_eq!(info.components.len(), 1);
872 assert_eq!(info.components[0].name, "status");
873 assert_eq!(info.components[0].content, "content\n");
874 }
875
876 #[test]
877 fn template_info_legacy_mode_works() {
878 let dir = setup_project();
879 let doc_path = dir.path().join("test.md");
880 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
881 std::fs::write(&doc_path, doc).unwrap();
882
883 let info = template_info(&doc_path).unwrap();
884 assert!(info.template_mode);
885 }
886
887 #[test]
888 fn template_info_append_mode() {
889 let dir = setup_project();
890 let doc_path = dir.path().join("test.md");
891 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
892 std::fs::write(&doc_path, doc).unwrap();
893
894 let info = template_info(&doc_path).unwrap();
895 assert!(!info.template_mode);
896 assert!(info.components.is_empty());
897 }
898
899 #[test]
900 fn parse_patches_ignores_markers_in_fenced_code_block() {
901 let response = "\
902<!-- patch:exchange -->
903Here is how you use component markers:
904
905```markdown
906<!-- agent:exchange -->
907example content
908<!-- /agent:exchange -->
909```
910
911<!-- /patch:exchange -->
912";
913 let (patches, unmatched) = parse_patches(response).unwrap();
914 assert_eq!(patches.len(), 1);
915 assert_eq!(patches[0].name, "exchange");
916 assert!(patches[0].content.contains("```markdown"));
917 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
918 assert!(unmatched.is_empty());
919 }
920
921 #[test]
922 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
923 let response = "\
925<!-- patch:exchange -->
926Real content here.
927
928```markdown
929<!-- patch:fake -->
930This is just an example.
931<!-- /patch:fake -->
932```
933
934<!-- /patch:exchange -->
935";
936 let (patches, unmatched) = parse_patches(response).unwrap();
937 assert_eq!(patches.len(), 1, "should only find the outer real patch");
938 assert_eq!(patches[0].name, "exchange");
939 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
940 assert!(unmatched.is_empty());
941 }
942
943 #[test]
944 fn parse_patches_ignores_markers_in_tilde_fence() {
945 let response = "\
946<!-- patch:status -->
947OK
948<!-- /patch:status -->
949
950~~~
951<!-- patch:fake -->
952example
953<!-- /patch:fake -->
954~~~
955";
956 let (patches, _unmatched) = parse_patches(response).unwrap();
957 assert_eq!(patches.len(), 1);
959 assert_eq!(patches[0].name, "status");
960 }
961
962 #[test]
963 fn parse_patches_ignores_closing_marker_in_code_block() {
964 let response = "\
967<!-- patch:exchange -->
968Example:
969
970```
971<!-- /patch:exchange -->
972```
973
974Real content continues.
975<!-- /patch:exchange -->
976";
977 let (patches, _unmatched) = parse_patches(response).unwrap();
978 assert_eq!(patches.len(), 1);
979 assert_eq!(patches[0].name, "exchange");
980 assert!(patches[0].content.contains("Real content continues."));
981 }
982
983 #[test]
984 fn parse_patches_normal_markers_still_work() {
985 let response = "\
987<!-- patch:status -->
988All systems go.
989<!-- /patch:status -->
990<!-- patch:log -->
991- Entry 1
992<!-- /patch:log -->
993";
994 let (patches, unmatched) = parse_patches(response).unwrap();
995 assert_eq!(patches.len(), 2);
996 assert_eq!(patches[0].name, "status");
997 assert_eq!(patches[0].content, "All systems go.\n");
998 assert_eq!(patches[1].name, "log");
999 assert_eq!(patches[1].content, "- Entry 1\n");
1000 assert!(unmatched.is_empty());
1001 }
1002
1003 #[test]
1006 fn inline_attr_mode_overrides_config() {
1007 let dir = setup_project();
1009 let doc_path = dir.path().join("test.md");
1010 std::fs::write(
1012 dir.path().join(".agent-doc/components.toml"),
1013 "[status]\nmode = \"append\"\n",
1014 ).unwrap();
1015 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
1017 std::fs::write(&doc_path, doc).unwrap();
1018
1019 let patches = vec![PatchBlock {
1020 name: "status".to_string(),
1021 content: "new\n".to_string(),
1022 attrs: Default::default(),
1023 }];
1024 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1025 assert!(result.contains("new\n"));
1027 assert!(!result.contains("old\n"));
1028 }
1029
1030 #[test]
1031 fn inline_attr_mode_overrides_default() {
1032 let dir = setup_project();
1034 let doc_path = dir.path().join("test.md");
1035 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
1036 std::fs::write(&doc_path, doc).unwrap();
1037
1038 let patches = vec![PatchBlock {
1039 name: "exchange".to_string(),
1040 content: "new\n".to_string(),
1041 attrs: Default::default(),
1042 }];
1043 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1044 assert!(result.contains("new\n"));
1045 assert!(!result.contains("old\n"));
1046 }
1047
1048 #[test]
1049 fn no_inline_attr_falls_back_to_config() {
1050 let dir = setup_project();
1052 let doc_path = dir.path().join("test.md");
1053 std::fs::write(
1054 dir.path().join(".agent-doc/components.toml"),
1055 "[status]\nmode = \"append\"\n",
1056 ).unwrap();
1057 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1058 std::fs::write(&doc_path, doc).unwrap();
1059
1060 let patches = vec![PatchBlock {
1061 name: "status".to_string(),
1062 content: "new\n".to_string(),
1063 attrs: Default::default(),
1064 }];
1065 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1066 assert!(result.contains("old\n"));
1068 assert!(result.contains("new\n"));
1069 }
1070
1071 #[test]
1072 fn no_inline_attr_no_config_falls_back_to_default() {
1073 let dir = setup_project();
1075 let doc_path = dir.path().join("test.md");
1076 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1077 std::fs::write(&doc_path, doc).unwrap();
1078
1079 let patches = vec![PatchBlock {
1080 name: "exchange".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"));
1087 assert!(result.contains("new\n"));
1088 }
1089
1090 #[test]
1091 fn inline_patch_attr_overrides_config() {
1092 let dir = setup_project();
1094 let doc_path = dir.path().join("test.md");
1095 std::fs::write(
1096 dir.path().join(".agent-doc/components.toml"),
1097 "[status]\nmode = \"append\"\n",
1098 ).unwrap();
1099 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1100 std::fs::write(&doc_path, doc).unwrap();
1101
1102 let patches = vec![PatchBlock {
1103 name: "status".to_string(),
1104 content: "new\n".to_string(),
1105 attrs: Default::default(),
1106 }];
1107 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1108 assert!(result.contains("new\n"));
1109 assert!(!result.contains("old\n"));
1110 }
1111
1112 #[test]
1113 fn inline_patch_attr_overrides_mode_attr() {
1114 let dir = setup_project();
1116 let doc_path = dir.path().join("test.md");
1117 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1118 std::fs::write(&doc_path, doc).unwrap();
1119
1120 let patches = vec![PatchBlock {
1121 name: "exchange".to_string(),
1122 content: "new\n".to_string(),
1123 attrs: Default::default(),
1124 }];
1125 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1126 assert!(result.contains("new\n"));
1127 assert!(!result.contains("old\n"));
1128 }
1129
1130 #[test]
1131 fn toml_patch_key_works() {
1132 let dir = setup_project();
1134 let doc_path = dir.path().join("test.md");
1135 std::fs::write(
1136 dir.path().join(".agent-doc/components.toml"),
1137 "[status]\npatch = \"append\"\n",
1138 ).unwrap();
1139 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1140 std::fs::write(&doc_path, doc).unwrap();
1141
1142 let patches = vec![PatchBlock {
1143 name: "status".to_string(),
1144 content: "new\n".to_string(),
1145 attrs: Default::default(),
1146 }];
1147 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1148 assert!(result.contains("old\n"));
1149 assert!(result.contains("new\n"));
1150 }
1151
1152 #[test]
1153 fn stream_override_beats_inline_attr() {
1154 let dir = setup_project();
1156 let doc_path = dir.path().join("test.md");
1157 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1158 std::fs::write(&doc_path, doc).unwrap();
1159
1160 let patches = vec![PatchBlock {
1161 name: "exchange".to_string(),
1162 content: "new\n".to_string(),
1163 attrs: Default::default(),
1164 }];
1165 let mut overrides = std::collections::HashMap::new();
1166 overrides.insert("exchange".to_string(), "replace".to_string());
1167 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1168 assert!(result.contains("new\n"));
1170 assert!(!result.contains("old\n"));
1171 }
1172
1173 #[test]
1174 fn apply_patches_ignores_component_tags_in_code_blocks() {
1175 let dir = setup_project();
1178 let doc_path = dir.path().join("test.md");
1179 let doc = "\
1180# Scaffold Guide
1181
1182Here is an example of a component:
1183
1184```markdown
1185<!-- agent:status -->
1186example scaffold content
1187<!-- /agent:status -->
1188```
1189
1190<!-- agent:status -->
1191real status content
1192<!-- /agent:status -->
1193";
1194 std::fs::write(&doc_path, doc).unwrap();
1195
1196 let patches = vec![PatchBlock {
1197 name: "status".to_string(),
1198 content: "patched status\n".to_string(),
1199 attrs: Default::default(),
1200 }];
1201 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1202
1203 assert!(result.contains("patched status\n"), "real component should receive the patch");
1205 assert!(result.contains("example scaffold content"), "code block content should be preserved");
1207 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1209 }
1210
1211 #[test]
1212 fn unmatched_content_uses_boundary_marker() {
1213 let dir = setup_project();
1214 let file = dir.path().join("test.md");
1215 let doc = concat!(
1216 "---\nagent_doc_format: template\n---\n",
1217 "<!-- agent:exchange patch=append -->\n",
1218 "User prompt here.\n",
1219 "<!-- agent:boundary:test-uuid-123 -->\n",
1220 "<!-- /agent:exchange -->\n",
1221 );
1222 std::fs::write(&file, doc).unwrap();
1223
1224 let patches = vec![];
1226 let unmatched = "### Re: Response\n\nResponse content here.\n";
1227
1228 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1229
1230 let prompt_pos = result.find("User prompt here.").unwrap();
1232 let response_pos = result.find("### Re: Response").unwrap();
1233 assert!(
1234 response_pos > prompt_pos,
1235 "response should appear after the user prompt (boundary insertion)"
1236 );
1237
1238 assert!(
1240 !result.contains("test-uuid-123"),
1241 "boundary marker should be consumed after insertion"
1242 );
1243 }
1244
1245 #[test]
1246 fn explicit_patch_uses_boundary_marker() {
1247 let dir = setup_project();
1248 let file = dir.path().join("test.md");
1249 let doc = concat!(
1250 "---\nagent_doc_format: template\n---\n",
1251 "<!-- agent:exchange patch=append -->\n",
1252 "User prompt here.\n",
1253 "<!-- agent:boundary:patch-uuid-456 -->\n",
1254 "<!-- /agent:exchange -->\n",
1255 );
1256 std::fs::write(&file, doc).unwrap();
1257
1258 let patches = vec![PatchBlock {
1260 name: "exchange".to_string(),
1261 content: "### Re: Response\n\nResponse content.\n".to_string(),
1262 attrs: Default::default(),
1263 }];
1264
1265 let result = apply_patches(doc, &patches, "", &file).unwrap();
1266
1267 let prompt_pos = result.find("User prompt here.").unwrap();
1269 let response_pos = result.find("### Re: Response").unwrap();
1270 assert!(
1271 response_pos > prompt_pos,
1272 "response should appear after user prompt"
1273 );
1274
1275 assert!(
1277 !result.contains("patch-uuid-456"),
1278 "boundary marker should be consumed by explicit patch"
1279 );
1280 }
1281
1282 #[test]
1283 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1284 let dir = setup_project();
1287 let file = dir.path().join("test.md");
1288 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1290 std::fs::write(&file, doc).unwrap();
1291
1292 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1293 let (patches, unmatched) = parse_patches(response).unwrap();
1294 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1295
1296 assert!(
1298 result.contains("<!-- agent:boundary:"),
1299 "boundary must be re-inserted even when original doc had no boundary: {result}"
1300 );
1301 }
1302
1303 #[test]
1304 fn boundary_survives_multiple_cycles() {
1305 let dir = setup_project();
1307 let file = dir.path().join("test.md");
1308 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1309 std::fs::write(&file, doc).unwrap();
1310
1311 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1313 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1314 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1315 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1316
1317 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1319 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1320 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1321 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1322 }
1323
1324 #[test]
1325 fn remove_all_boundaries_skips_code_blocks() {
1326 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1327 let result = remove_all_boundaries(doc);
1328 assert!(
1330 result.contains("<!-- agent:boundary:fake-id -->"),
1331 "boundary inside code block must be preserved: {result}"
1332 );
1333 assert!(
1335 !result.contains("<!-- agent:boundary:real-id -->"),
1336 "boundary outside code block must be removed: {result}"
1337 );
1338 }
1339
1340 #[test]
1341 fn reposition_boundary_moves_to_end() {
1342 let doc = "\
1343<!-- agent:exchange -->
1344Previous response.
1345<!-- agent:boundary:old-id -->
1346User prompt here.
1347<!-- /agent:exchange -->";
1348 let result = reposition_boundary_to_end(doc);
1349 assert!(!result.contains("old-id"), "old boundary should be removed");
1351 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1353 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1355 let prompt_pos = result.find("User prompt here.").unwrap();
1356 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1357 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1358 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1359 }
1360
1361 #[test]
1362 fn reposition_boundary_no_exchange_unchanged() {
1363 let doc = "\
1364<!-- agent:output -->
1365Some content.
1366<!-- /agent:output -->";
1367 let result = reposition_boundary_to_end(doc);
1368 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1369 }
1370
1371 #[test]
1372 fn max_lines_inline_attr_trims_content() {
1373 let dir = setup_project();
1374 let doc_path = dir.path().join("test.md");
1375 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1376 std::fs::write(&doc_path, doc).unwrap();
1377
1378 let patches = vec![PatchBlock {
1379 name: "log".to_string(),
1380 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1381 attrs: Default::default(),
1382 }];
1383 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1384 assert!(!result.contains("line1"));
1385 assert!(!result.contains("line2"));
1386 assert!(result.contains("line3"));
1387 assert!(result.contains("line4"));
1388 assert!(result.contains("line5"));
1389 }
1390
1391 #[test]
1392 fn max_lines_noop_when_under_limit() {
1393 let dir = setup_project();
1394 let doc_path = dir.path().join("test.md");
1395 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1396 std::fs::write(&doc_path, doc).unwrap();
1397
1398 let patches = vec![PatchBlock {
1399 name: "log".to_string(),
1400 content: "line1\nline2\n".to_string(),
1401 attrs: Default::default(),
1402 }];
1403 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1404 assert!(result.contains("line1"));
1405 assert!(result.contains("line2"));
1406 }
1407
1408 #[test]
1409 fn max_lines_from_components_toml() {
1410 let dir = setup_project();
1411 let doc_path = dir.path().join("test.md");
1412 std::fs::write(
1413 dir.path().join(".agent-doc/components.toml"),
1414 "[log]\npatch = \"replace\"\nmax_lines = 2\n",
1415 )
1416 .unwrap();
1417 let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1418 std::fs::write(&doc_path, doc).unwrap();
1419
1420 let patches = vec![PatchBlock {
1421 name: "log".to_string(),
1422 content: "a\nb\nc\nd\n".to_string(),
1423 attrs: Default::default(),
1424 }];
1425 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1426 assert!(!result.contains("\na\n"));
1427 assert!(!result.contains("\nb\n"));
1428 assert!(result.contains("c"));
1429 assert!(result.contains("d"));
1430 }
1431
1432 #[test]
1433 fn max_lines_inline_beats_toml() {
1434 let dir = setup_project();
1435 let doc_path = dir.path().join("test.md");
1436 std::fs::write(
1437 dir.path().join(".agent-doc/components.toml"),
1438 "[log]\nmax_lines = 1\n",
1439 )
1440 .unwrap();
1441 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1442 std::fs::write(&doc_path, doc).unwrap();
1443
1444 let patches = vec![PatchBlock {
1445 name: "log".to_string(),
1446 content: "a\nb\nc\nd\n".to_string(),
1447 attrs: Default::default(),
1448 }];
1449 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1450 assert!(result.contains("b"));
1452 assert!(result.contains("c"));
1453 assert!(result.contains("d"));
1454 }
1455
1456 #[test]
1457 fn parse_patch_with_transfer_source_attr() {
1458 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
1459 let (patches, unmatched) = parse_patches(response).unwrap();
1460 assert_eq!(patches.len(), 1);
1461 assert_eq!(patches[0].name, "exchange");
1462 assert_eq!(patches[0].content, "Transferred content.\n");
1463 assert_eq!(
1464 patches[0].attrs.get("transfer-source"),
1465 Some(&"\"tasks/eval-runner.md\"".to_string())
1466 );
1467 assert!(unmatched.is_empty());
1468 }
1469
1470 #[test]
1471 fn parse_patch_without_attrs() {
1472 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
1473 let (patches, _) = parse_patches(response).unwrap();
1474 assert_eq!(patches.len(), 1);
1475 assert!(patches[0].attrs.is_empty());
1476 }
1477
1478 #[test]
1479 fn parse_patch_with_multiple_attrs() {
1480 let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
1481 let (patches, _) = parse_patches(response).unwrap();
1482 assert_eq!(patches.len(), 1);
1483 assert_eq!(patches[0].name, "output");
1484 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
1485 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
1486 }
1487
1488 #[test]
1489 fn apply_patches_dedup_exchange_adjacent_echo() {
1490 let dir = setup_project();
1494 let doc_path = dir.path().join("test.md");
1495 let doc = "\
1496<!-- agent:exchange patch=append -->
1497❯ How do I configure .mise.toml?
1498<!-- /agent:exchange -->
1499";
1500 std::fs::write(&doc_path, doc).unwrap();
1501
1502 let patches = vec![PatchBlock {
1504 name: "exchange".to_string(),
1505 content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
1506 attrs: Default::default(),
1507 }];
1508 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1509
1510 let count = result.matches("❯ How do I configure .mise.toml?").count();
1511 assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
1512 assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
1513 assert!(result.contains("Use `[env]` section."), "response body should be present");
1514 }
1515
1516 #[test]
1517 fn apply_patches_dedup_preserves_blank_lines() {
1518 let dir = setup_project();
1520 let doc_path = dir.path().join("test.md");
1521 let doc = "\
1522<!-- agent:exchange patch=append -->
1523Previous response.
1524<!-- /agent:exchange -->
1525";
1526 std::fs::write(&doc_path, doc).unwrap();
1527
1528 let patches = vec![PatchBlock {
1529 name: "exchange".to_string(),
1530 content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
1531 attrs: Default::default(),
1532 }];
1533 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1534 assert!(result.contains("Previous response."), "existing content preserved");
1535 assert!(result.contains("### Re: something"), "response heading present");
1536 assert!(result.contains('\n'), "blank lines preserved");
1538 }
1539
1540 #[test]
1541 fn apply_mode_append_strips_leading_overlap() {
1542 let existing = "❯ How do I configure .mise.toml?\n";
1545 let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
1546 let result = apply_mode("append", existing, new_content);
1547 let count = result.matches("❯ How do I configure .mise.toml?").count();
1548 assert_eq!(count, 1, "overlap line should appear exactly once");
1549 assert!(result.contains("### Re: configure"));
1550 }
1551
1552 #[test]
1553 fn apply_mode_append_no_overlap_unchanged() {
1554 let existing = "Previous content.\n";
1557 let new_content = "### Re: something\n\nAnswer.\n";
1558 let result = apply_mode("append", existing, new_content);
1559 assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
1560 }
1561}