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)> {
142 let bytes = response.as_bytes();
143 let len = bytes.len();
144 let code_ranges = component::find_code_ranges(response);
145 let mut patches = Vec::new();
146 let mut unmatched = String::new();
147 let mut pos = 0;
148 let mut last_end = 0;
149
150 while pos + 4 <= len {
151 if &bytes[pos..pos + 4] != b"<!--" {
152 pos += 1;
153 continue;
154 }
155
156 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
158 pos += 4;
159 continue;
160 }
161
162 let marker_start = pos;
163
164 let close = match find_comment_end(bytes, pos + 4) {
166 Some(c) => c,
167 None => {
168 pos += 4;
169 continue;
170 }
171 };
172
173 let inner = &response[marker_start + 4..close - 3];
174 let trimmed = inner.trim();
175
176 let parsed_prefix: Option<(&str, &str)> = if let Some(rest) = trimmed.strip_prefix("patch:") {
180 Some(("patch", rest))
181 } else if let Some(rest) = trimmed.strip_prefix("replace:") {
182 let rest_trim = rest.trim_start();
185 let name_end = rest_trim
186 .find(|c: char| c.is_whitespace())
187 .unwrap_or(rest_trim.len());
188 if &rest_trim[..name_end] == "pending" {
189 Some(("replace", rest))
190 } else {
191 None
192 }
193 } else {
194 None
195 };
196
197 if let Some((prefix_kind, rest)) = parsed_prefix {
198 let rest = rest.trim();
199 if rest.is_empty() || rest.starts_with('/') {
200 pos = close;
201 continue;
202 }
203
204 let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
206 let name = &rest[..space_idx];
207 let attr_text = rest[space_idx..].trim();
208 (name, component::parse_attrs(attr_text))
209 } else {
210 (rest, std::collections::HashMap::new())
211 };
212
213 if prefix_kind == "patch" && name == "pending" {
216 eprintln!(
217 "warning: `<!-- patch:pending -->` is deprecated — use `<!-- replace:pending -->` instead (see #25ag)"
218 );
219 }
220
221 let mut content_start = close;
223 if content_start < len && bytes[content_start] == b'\n' {
224 content_start += 1;
225 }
226
227 let before = &response[last_end..marker_start];
229 let trimmed_before = before.trim();
230 if !trimmed_before.is_empty() {
231 if !unmatched.is_empty() {
232 unmatched.push('\n');
233 }
234 unmatched.push_str(trimmed_before);
235 }
236
237 let close_marker = format!("<!-- /{}:{} -->", prefix_kind, name);
240 if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
241 let content = &response[content_start..close_pos];
242 patches.push(PatchBlock {
243 name: name.to_string(),
244 content: content.to_string(),
245 attrs,
246 });
247
248 let mut end = close_pos + close_marker.len();
249 if end < len && bytes[end] == b'\n' {
250 end += 1;
251 }
252 last_end = end;
253 pos = end;
254 continue;
255 }
256 }
257
258 pos = close;
259 }
260
261 if last_end < len {
263 let trailing = response[last_end..].trim();
264 if !trailing.is_empty() {
265 if !unmatched.is_empty() {
266 unmatched.push('\n');
267 }
268 unmatched.push_str(trailing);
269 }
270 }
271
272 Ok((patches, unmatched))
273}
274
275pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
284 apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
285}
286
287pub(crate) fn strip_trailing_caret_lines(content: &str) -> String {
299 let trailing_nl = content.ends_with('\n');
300 let mut lines: Vec<&str> = content.split('\n').collect();
301 if trailing_nl {
304 lines.pop();
305 }
306 while let Some(last) = lines.last() {
307 let t = last.trim();
308 if t == "❯" {
309 lines.pop();
310 } else {
311 break;
312 }
313 }
314 let mut out = lines.join("\n");
315 if trailing_nl {
316 out.push('\n');
317 }
318 out
319}
320
321pub fn apply_patches_with_overrides(
324 doc: &str,
325 patches: &[PatchBlock],
326 unmatched: &str,
327 file: &Path,
328 mode_overrides: &std::collections::HashMap<String, String>,
329) -> Result<String> {
330 let summary = file.file_stem().and_then(|s| s.to_str());
335 let mut result = remove_all_boundaries(doc);
336 if let Ok(components) = component::parse(&result)
337 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
338 {
339 let id = crate::new_boundary_id_with_summary(summary);
340 let marker = crate::format_boundary_marker(&id);
341 let content = exchange.content(&result);
342 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
343 result = exchange.replace_content(&result, &new_content);
344 eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
345 }
346
347 let components = component::parse(&result)
349 .context("failed to parse components")?;
350
351 let configs = load_component_configs(file);
353
354 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
359 let mut overflow = String::new();
360 for patch in patches {
361 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
362 ops.push((idx, patch));
363 } else {
364 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
365 eprintln!(
366 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
367 patch.name,
368 available.join(", ")
369 );
370 if !overflow.is_empty() {
371 overflow.push('\n');
372 }
373 overflow.push_str(&patch.content);
374 }
375 }
376
377 ops.sort_by(|a, b| b.0.cmp(&a.0));
379
380 for (idx, patch) in &ops {
381 let comp = &components[*idx];
382 let mode = mode_overrides.get(&patch.name)
384 .map(|s| s.as_str())
385 .or_else(|| comp.patch_mode())
386 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
387 .unwrap_or_else(|| default_mode(&patch.name));
388 let patch_content: std::borrow::Cow<'_, str> = if patch.name == "exchange" {
391 std::borrow::Cow::Owned(strip_trailing_caret_lines(&patch.content))
392 } else {
393 std::borrow::Cow::Borrowed(patch.content.as_str())
394 };
395 if mode == "append"
397 && let Some(bid) = find_boundary_in_component(&result, comp)
398 {
399 result = comp.append_with_boundary(&result, &patch_content, &bid);
400 continue;
401 }
402 let new_content = apply_mode(mode, comp.content(&result), &patch_content);
403 result = comp.replace_content(&result, &new_content);
404 }
405
406 let mut all_unmatched = String::new();
408 if !overflow.is_empty() {
409 all_unmatched.push_str(&overflow);
410 }
411 if !unmatched.is_empty() {
412 if !all_unmatched.is_empty() {
413 all_unmatched.push('\n');
414 }
415 all_unmatched.push_str(unmatched);
416 }
417
418 if !all_unmatched.is_empty() {
420 let components = component::parse(&result)
422 .context("failed to re-parse components after patching")?;
423
424 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
425 let stripped = if output_comp.name == "exchange" {
428 strip_trailing_caret_lines(&all_unmatched)
429 } else {
430 all_unmatched.clone()
431 };
432 let unmatched = &stripped;
433 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
435 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
436 result = output_comp.append_with_boundary(&result, unmatched, &bid);
437 } else {
438 let existing = output_comp.content(&result);
440 let new_content = if existing.trim().is_empty() {
441 format!("{}\n", unmatched)
442 } else {
443 format!("{}{}\n", existing, unmatched)
444 };
445 result = output_comp.replace_content(&result, &new_content);
446 }
447 } else {
448 let stripped = strip_trailing_caret_lines(&all_unmatched);
450 if !result.ends_with('\n') {
451 result.push('\n');
452 }
453 result.push_str("\n<!-- agent:exchange -->\n");
454 result.push_str(&stripped);
455 result.push_str("\n<!-- /agent:exchange -->\n");
456 }
457 }
458
459 result = dedup_exchange_adjacent_lines(&result);
462
463 {
469 let max_lines_configs = load_max_lines_configs(file);
470 'stability: for _ in 0..3 {
471 let Ok(components) = component::parse(&result) else { break };
472 for comp in &components {
473 let max_lines = comp
474 .attrs
475 .get("max_lines")
476 .and_then(|s| s.parse::<usize>().ok())
477 .or_else(|| max_lines_configs.get(&comp.name).copied())
478 .unwrap_or(0);
479 if max_lines > 0 {
480 let content = comp.content(&result);
481 let trimmed = limit_lines(content, max_lines);
482 if trimmed.len() != content.len() {
483 let trimmed = format!("{}\n", trimmed.trim_end());
484 result = comp.replace_content(&result, &trimmed);
485 continue 'stability;
487 }
488 }
489 }
490 break; }
492 }
493
494 {
500 if let Ok(components) = component::parse(&result)
501 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
502 && find_boundary_in_component(&result, exchange).is_none()
503 {
504 let id = uuid::Uuid::new_v4().to_string();
506 let marker = format!("<!-- agent:boundary:{} -->", id);
507 let content = exchange.content(&result);
508 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
509 result = exchange.replace_content(&result, &new_content);
510 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
511 }
512 }
513
514 Ok(result)
515}
516
517pub fn reposition_boundary_to_end(doc: &str) -> String {
525 reposition_boundary_to_end_with_summary(doc, None)
526}
527
528pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
533 reposition_boundary_to_end_with_baseline(doc, summary, None)
534}
535
536pub fn reposition_boundary_to_end_with_baseline(
545 doc: &str,
546 summary: Option<&str>,
547 baseline_headings: Option<&std::collections::HashSet<String>>,
548) -> String {
549 let mut result = remove_all_boundaries(doc);
550 if let Ok(components) = component::parse(&result)
551 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
552 {
553 let id = crate::new_boundary_id_with_summary(summary);
554 let marker = crate::format_boundary_marker(&id);
555 let content = exchange.content(&result).to_string();
556 let annotated = annotate_re_headings_with_head(&content, baseline_headings);
557 let new_content = format!("{}\n{}\n", annotated.trim_end(), marker);
558 result = exchange.replace_content(&result, &new_content);
559 }
560 result
561}
562
563pub fn exchange_baseline_headings(doc: &str) -> std::collections::HashSet<String> {
571 if let Ok(components) = component::parse(doc)
572 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
573 {
574 return collect_re_headings(exchange.content(doc));
575 }
576 std::collections::HashSet::new()
577}
578
579fn collect_re_headings(content: &str) -> std::collections::HashSet<String> {
583 let code_ranges = component::find_code_ranges(content);
584 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
585 let mut set = std::collections::HashSet::new();
586 let mut offset = 0usize;
587 for line in content.split_inclusive('\n') {
588 let line_start = offset;
589 offset += line.len();
590 if in_code(line_start) {
591 continue;
592 }
593 let body = line.trim_end_matches('\n').trim_end_matches('\r');
594 let trimmed = body.trim_start();
595 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
596 if hash_count == 0 || hash_count > 6 {
597 continue;
598 }
599 let after_hash = &trimmed[hash_count..];
600 if !after_hash.starts_with(' ') {
601 continue;
602 }
603 if !after_hash.trim_start().starts_with("Re:") {
604 continue;
605 }
606 let stripped = body
607 .trim_start()
608 .trim_end()
609 .trim_end_matches(" (HEAD)")
610 .to_string();
611 set.insert(stripped);
612 }
613 set
614}
615
616pub(crate) fn annotate_re_headings_with_head(
635 content: &str,
636 baseline: Option<&std::collections::HashSet<String>>,
637) -> String {
638 let code_ranges = component::find_code_ranges(content);
639 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
640
641 let mut lines: Vec<String> = content.split_inclusive('\n').map(|s| s.to_string()).collect();
642 let mut re_indices: Vec<usize> = Vec::new();
643 let mut offset = 0usize;
644
645 for (idx, line) in lines.iter_mut().enumerate() {
646 let line_start = offset;
647 offset += line.len();
648 if in_code(line_start) {
649 continue;
650 }
651 let had_newline = line.ends_with('\n');
652 let body_ref = line.trim_end_matches('\n').trim_end_matches('\r');
653 let trimmed = body_ref.trim_start();
654 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
655 if hash_count == 0 || hash_count > 6 {
656 continue;
657 }
658 let after_hash = &trimmed[hash_count..];
659 if !after_hash.starts_with(' ') {
660 continue;
661 }
662 if !after_hash.trim_start().starts_with("Re:") {
663 continue;
664 }
665 let stripped = body_ref.trim_end().trim_end_matches(" (HEAD)");
667 *line = if had_newline {
668 format!("{stripped}\n")
669 } else {
670 stripped.to_string()
671 };
672 re_indices.push(idx);
673 }
674
675 let mark_indices: Vec<usize> = match baseline {
685 Some(baseline_set) => {
686 let filtered: Vec<usize> = re_indices
687 .iter()
688 .copied()
689 .filter(|&idx| {
690 let line = &lines[idx];
691 let key = line
692 .trim_end_matches('\n')
693 .trim_end_matches('\r')
694 .trim_start()
695 .trim_end();
696 !baseline_set.contains(key)
697 })
698 .collect();
699 if filtered.is_empty() {
700 re_indices.last().copied().into_iter().collect()
701 } else {
702 filtered
703 }
704 }
705 None => re_indices.last().copied().into_iter().collect(),
706 };
707
708 for idx in mark_indices {
709 let line = &lines[idx];
710 let had_newline = line.ends_with('\n');
711 let body = line.trim_end_matches('\n').trim_end_matches('\r');
712 lines[idx] = if had_newline {
713 format!("{body} (HEAD)\n")
714 } else {
715 format!("{body} (HEAD)")
716 };
717 }
718
719 lines.concat()
720}
721
722
723fn remove_all_boundaries(doc: &str) -> String {
726 let prefix = "<!-- agent:boundary:";
727 let suffix = " -->";
728 let code_ranges = component::find_code_ranges(doc);
729 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
730 let mut result = String::with_capacity(doc.len());
731 let mut offset = 0;
732 for line in doc.lines() {
733 let trimmed = line.trim();
734 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
735 if is_boundary && !in_code(offset) {
736 offset += line.len() + 1; continue;
739 }
740 result.push_str(line);
741 result.push('\n');
742 offset += line.len() + 1;
743 }
744 if !doc.ends_with('\n') && result.ends_with('\n') {
745 result.pop();
746 }
747 result
748}
749
750fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
752 let prefix = "<!-- agent:boundary:";
753 let suffix = " -->";
754 let content_region = &doc[comp.open_end..comp.close_start];
755 let code_ranges = component::find_code_ranges(doc);
756 let mut search_from = 0;
757 while let Some(start) = content_region[search_from..].find(prefix) {
758 let abs_start = comp.open_end + search_from + start;
759 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
760 search_from += start + prefix.len();
761 continue;
762 }
763 let after_prefix = &content_region[search_from + start + prefix.len()..];
764 if let Some(end) = after_prefix.find(suffix) {
765 return Some(after_prefix[..end].trim().to_string());
766 }
767 break;
768 }
769 None
770}
771
772pub fn template_info(file: &Path) -> Result<TemplateInfo> {
774 let doc = std::fs::read_to_string(file)
775 .with_context(|| format!("failed to read {}", file.display()))?;
776
777 let (fm, _body) = crate::frontmatter::parse(&doc)?;
778 let template_mode = fm.resolve_mode().is_template();
779
780 let components = component::parse(&doc)
781 .with_context(|| format!("failed to parse components in {}", file.display()))?;
782
783 let configs = load_component_configs(file);
784
785 let component_infos: Vec<ComponentInfo> = components
786 .iter()
787 .map(|comp| {
788 let content = comp.content(&doc).to_string();
789 let mode = comp.patch_mode().map(|s| s.to_string())
791 .or_else(|| configs.get(&comp.name).cloned())
792 .unwrap_or_else(|| default_mode(&comp.name).to_string());
793 let line = doc[..comp.open_start].matches('\n').count() + 1;
795 ComponentInfo {
796 name: comp.name.clone(),
797 mode,
798 content,
799 line,
800 max_entries: None, }
802 })
803 .collect();
804
805 Ok(TemplateInfo {
806 template_mode,
807 components: component_infos,
808 })
809}
810
811fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
815 let proj_cfg = load_project_from_doc(file);
816 proj_cfg
817 .components
818 .iter()
819 .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.patch.clone()))
820 .collect()
821}
822
823fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
826 let proj_cfg = load_project_from_doc(file);
827 proj_cfg
828 .components
829 .iter()
830 .filter(|(_, cfg)| cfg.max_lines > 0)
831 .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.max_lines))
832 .collect()
833}
834
835fn load_project_from_doc(file: &Path) -> project_config::ProjectConfig {
837 let start = file.parent().unwrap_or(file);
838 let mut current = start;
839 loop {
840 let candidate = current.join(".agent-doc").join("config.toml");
841 if candidate.exists() {
842 return project_config::load_project_from(&candidate);
843 }
844 match current.parent() {
845 Some(p) if p != current => current = p,
846 _ => break,
847 }
848 }
849 project_config::load_project()
851}
852
853fn default_mode(name: &str) -> &'static str {
856 match name {
857 "exchange" | "findings" => "append",
858 _ => "replace",
859 }
860}
861
862fn limit_lines(content: &str, max_lines: usize) -> String {
864 let lines: Vec<&str> = content.lines().collect();
865 if lines.len() <= max_lines {
866 return content.to_string();
867 }
868 lines[lines.len() - max_lines..].join("\n")
869}
870
871fn dedup_exchange_adjacent_lines(doc: &str) -> String {
880 let Ok(components) = component::parse(doc) else {
881 return doc.to_string();
882 };
883 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
884 return doc.to_string();
885 };
886 let content = exchange.content(doc);
887 let mut deduped = String::with_capacity(content.len());
888 let mut prev_nonempty: Option<&str> = None;
889 for line in content.lines() {
890 if !line.trim().is_empty() && prev_nonempty == Some(line) {
891 continue;
893 }
894 deduped.push_str(line);
895 deduped.push('\n');
896 if !line.trim().is_empty() {
897 prev_nonempty = Some(line);
898 }
899 }
900 if !content.ends_with('\n') && deduped.ends_with('\n') {
902 deduped.pop();
903 }
904 if deduped == content {
905 return doc.to_string();
906 }
907 exchange.replace_content(doc, &deduped)
908}
909
910fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
912 match mode {
913 "append" => {
914 let stripped = strip_leading_overlap(existing, new_content);
915 format!("{}{}", existing, stripped)
916 }
917 "prepend" => format!("{}{}", new_content, existing),
918 _ => new_content.to_string(), }
920}
921
922fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
927 let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
928 let Some(last) = last_nonempty else {
929 return new_content;
930 };
931 let test = format!("{}\n", last);
932 if new_content.starts_with(test.as_str()) {
933 &new_content[test.len()..]
934 } else {
935 new_content
936 }
937}
938
939#[allow(dead_code)]
940fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
941 let canonical = file.canonicalize().ok()?;
942 let mut dir = canonical.parent()?;
943 loop {
944 if dir.join(".agent-doc").is_dir() {
945 return Some(dir.to_path_buf());
946 }
947 dir = dir.parent()?;
948 }
949}
950
951fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
954 let mut search_start = from;
955 loop {
956 let rel = haystack[search_start..].find(needle)?;
957 let abs = search_start + rel;
958 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
959 search_start = abs + needle.len();
961 continue;
962 }
963 return Some(abs);
964 }
965}
966
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use tempfile::TempDir;
972
973 fn setup_project() -> TempDir {
974 let dir = TempDir::new().unwrap();
975 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
976 dir
977 }
978
979 #[test]
980 fn parse_single_patch() {
981 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
982 let (patches, unmatched) = parse_patches(response).unwrap();
983 assert_eq!(patches.len(), 1);
984 assert_eq!(patches[0].name, "status");
985 assert_eq!(patches[0].content, "Build passing.\n");
986 assert!(unmatched.is_empty());
987 }
988
989 #[test]
990 fn parse_multiple_patches() {
991 let response = "\
992<!-- patch:status -->
993All green.
994<!-- /patch:status -->
995
996<!-- patch:log -->
997- New entry
998<!-- /patch:log -->
999";
1000 let (patches, unmatched) = parse_patches(response).unwrap();
1001 assert_eq!(patches.len(), 2);
1002 assert_eq!(patches[0].name, "status");
1003 assert_eq!(patches[0].content, "All green.\n");
1004 assert_eq!(patches[1].name, "log");
1005 assert_eq!(patches[1].content, "- New entry\n");
1006 assert!(unmatched.is_empty());
1007 }
1008
1009 #[test]
1010 fn parse_with_unmatched_content() {
1011 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
1012 let (patches, unmatched) = parse_patches(response).unwrap();
1013 assert_eq!(patches.len(), 1);
1014 assert_eq!(patches[0].name, "status");
1015 assert!(unmatched.contains("Some free text."));
1016 assert!(unmatched.contains("Trailing text."));
1017 }
1018
1019 #[test]
1020 fn parse_empty_response() {
1021 let (patches, unmatched) = parse_patches("").unwrap();
1022 assert!(patches.is_empty());
1023 assert!(unmatched.is_empty());
1024 }
1025
1026 #[test]
1027 fn parse_no_patches() {
1028 let response = "Just a plain response with no patch blocks.";
1029 let (patches, unmatched) = parse_patches(response).unwrap();
1030 assert!(patches.is_empty());
1031 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
1032 }
1033
1034 #[test]
1035 fn apply_patches_replace() {
1036 let dir = setup_project();
1037 let doc_path = dir.path().join("test.md");
1038 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1039 std::fs::write(&doc_path, doc).unwrap();
1040
1041 let patches = vec![PatchBlock {
1042 name: "status".to_string(),
1043 content: "new\n".to_string(),
1044 attrs: Default::default(),
1045 }];
1046 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1047 assert!(result.contains("new\n"));
1048 assert!(!result.contains("\nold\n"));
1049 assert!(result.contains("<!-- agent:status -->"));
1050 }
1051
1052 #[test]
1053 fn apply_patches_unmatched_creates_exchange() {
1054 let dir = setup_project();
1055 let doc_path = dir.path().join("test.md");
1056 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
1057 std::fs::write(&doc_path, doc).unwrap();
1058
1059 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
1060 assert!(result.contains("<!-- agent:exchange -->"));
1061 assert!(result.contains("Extra info here"));
1062 assert!(result.contains("<!-- /agent:exchange -->"));
1063 }
1064
1065 #[test]
1066 fn apply_patches_unmatched_appends_to_existing_exchange() {
1067 let dir = setup_project();
1068 let doc_path = dir.path().join("test.md");
1069 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
1070 std::fs::write(&doc_path, doc).unwrap();
1071
1072 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
1073 assert!(result.contains("previous"));
1074 assert!(result.contains("new stuff"));
1075 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
1077 }
1078
1079 #[test]
1080 fn apply_patches_missing_component_routes_to_exchange() {
1081 let dir = setup_project();
1082 let doc_path = dir.path().join("test.md");
1083 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
1084 std::fs::write(&doc_path, doc).unwrap();
1085
1086 let patches = vec![PatchBlock {
1087 name: "nonexistent".to_string(),
1088 content: "overflow data\n".to_string(),
1089 attrs: Default::default(),
1090 }];
1091 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1092 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
1094 assert!(result.contains("previous"), "existing exchange content should be preserved");
1095 }
1096
1097 #[test]
1098 fn apply_patches_missing_component_creates_exchange() {
1099 let dir = setup_project();
1100 let doc_path = dir.path().join("test.md");
1101 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
1102 std::fs::write(&doc_path, doc).unwrap();
1103
1104 let patches = vec![PatchBlock {
1105 name: "nonexistent".to_string(),
1106 content: "overflow data\n".to_string(),
1107 attrs: Default::default(),
1108 }];
1109 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1110 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
1112 assert!(result.contains("overflow data"), "overflow content should be in exchange");
1113 }
1114
1115 #[test]
1116 fn is_template_mode_detection() {
1117 assert!(is_template_mode(Some("template")));
1118 assert!(!is_template_mode(Some("append")));
1119 assert!(!is_template_mode(None));
1120 }
1121
1122 #[test]
1123 fn template_info_works() {
1124 let dir = setup_project();
1125 let doc_path = dir.path().join("test.md");
1126 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1127 std::fs::write(&doc_path, doc).unwrap();
1128
1129 let info = template_info(&doc_path).unwrap();
1130 assert!(info.template_mode);
1131 assert_eq!(info.components.len(), 1);
1132 assert_eq!(info.components[0].name, "status");
1133 assert_eq!(info.components[0].content, "content\n");
1134 }
1135
1136 #[test]
1137 fn template_info_legacy_mode_works() {
1138 let dir = setup_project();
1139 let doc_path = dir.path().join("test.md");
1140 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1141 std::fs::write(&doc_path, doc).unwrap();
1142
1143 let info = template_info(&doc_path).unwrap();
1144 assert!(info.template_mode);
1145 }
1146
1147 #[test]
1148 fn template_info_append_mode() {
1149 let dir = setup_project();
1150 let doc_path = dir.path().join("test.md");
1151 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
1152 std::fs::write(&doc_path, doc).unwrap();
1153
1154 let info = template_info(&doc_path).unwrap();
1155 assert!(!info.template_mode);
1156 assert!(info.components.is_empty());
1157 }
1158
1159 #[test]
1160 fn parse_patches_ignores_markers_in_fenced_code_block() {
1161 let response = "\
1162<!-- patch:exchange -->
1163Here is how you use component markers:
1164
1165```markdown
1166<!-- agent:exchange -->
1167example content
1168<!-- /agent:exchange -->
1169```
1170
1171<!-- /patch:exchange -->
1172";
1173 let (patches, unmatched) = parse_patches(response).unwrap();
1174 assert_eq!(patches.len(), 1);
1175 assert_eq!(patches[0].name, "exchange");
1176 assert!(patches[0].content.contains("```markdown"));
1177 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
1178 assert!(unmatched.is_empty());
1179 }
1180
1181 #[test]
1182 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
1183 let response = "\
1185<!-- patch:exchange -->
1186Real content here.
1187
1188```markdown
1189<!-- patch:fake -->
1190This is just an example.
1191<!-- /patch:fake -->
1192```
1193
1194<!-- /patch:exchange -->
1195";
1196 let (patches, unmatched) = parse_patches(response).unwrap();
1197 assert_eq!(patches.len(), 1, "should only find the outer real patch");
1198 assert_eq!(patches[0].name, "exchange");
1199 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
1200 assert!(unmatched.is_empty());
1201 }
1202
1203 #[test]
1204 fn parse_patches_ignores_markers_in_tilde_fence() {
1205 let response = "\
1206<!-- patch:status -->
1207OK
1208<!-- /patch:status -->
1209
1210~~~
1211<!-- patch:fake -->
1212example
1213<!-- /patch:fake -->
1214~~~
1215";
1216 let (patches, _unmatched) = parse_patches(response).unwrap();
1217 assert_eq!(patches.len(), 1);
1219 assert_eq!(patches[0].name, "status");
1220 }
1221
1222 #[test]
1223 fn parse_patches_ignores_closing_marker_in_code_block() {
1224 let response = "\
1227<!-- patch:exchange -->
1228Example:
1229
1230```
1231<!-- /patch:exchange -->
1232```
1233
1234Real content continues.
1235<!-- /patch:exchange -->
1236";
1237 let (patches, _unmatched) = parse_patches(response).unwrap();
1238 assert_eq!(patches.len(), 1);
1239 assert_eq!(patches[0].name, "exchange");
1240 assert!(patches[0].content.contains("Real content continues."));
1241 }
1242
1243 #[test]
1244 fn parse_patches_normal_markers_still_work() {
1245 let response = "\
1247<!-- patch:status -->
1248All systems go.
1249<!-- /patch:status -->
1250<!-- patch:log -->
1251- Entry 1
1252<!-- /patch:log -->
1253";
1254 let (patches, unmatched) = parse_patches(response).unwrap();
1255 assert_eq!(patches.len(), 2);
1256 assert_eq!(patches[0].name, "status");
1257 assert_eq!(patches[0].content, "All systems go.\n");
1258 assert_eq!(patches[1].name, "log");
1259 assert_eq!(patches[1].content, "- Entry 1\n");
1260 assert!(unmatched.is_empty());
1261 }
1262
1263 #[test]
1266 fn inline_attr_mode_overrides_config() {
1267 let dir = setup_project();
1269 let doc_path = dir.path().join("test.md");
1270 std::fs::write(
1272 dir.path().join(".agent-doc/config.toml"),
1273 "[components.status]\npatch = \"append\"\n",
1274 ).unwrap();
1275 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
1277 std::fs::write(&doc_path, doc).unwrap();
1278
1279 let patches = vec![PatchBlock {
1280 name: "status".to_string(),
1281 content: "new\n".to_string(),
1282 attrs: Default::default(),
1283 }];
1284 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1285 assert!(result.contains("new\n"));
1287 assert!(!result.contains("old\n"));
1288 }
1289
1290 #[test]
1291 fn inline_attr_mode_overrides_default() {
1292 let dir = setup_project();
1294 let doc_path = dir.path().join("test.md");
1295 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
1296 std::fs::write(&doc_path, doc).unwrap();
1297
1298 let patches = vec![PatchBlock {
1299 name: "exchange".to_string(),
1300 content: "new\n".to_string(),
1301 attrs: Default::default(),
1302 }];
1303 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1304 assert!(result.contains("new\n"));
1305 assert!(!result.contains("old\n"));
1306 }
1307
1308 #[test]
1309 fn no_inline_attr_falls_back_to_config() {
1310 let dir = setup_project();
1312 let doc_path = dir.path().join("test.md");
1313 std::fs::write(
1314 dir.path().join(".agent-doc/config.toml"),
1315 "[components.status]\npatch = \"append\"\n",
1316 ).unwrap();
1317 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1318 std::fs::write(&doc_path, doc).unwrap();
1319
1320 let patches = vec![PatchBlock {
1321 name: "status".to_string(),
1322 content: "new\n".to_string(),
1323 attrs: Default::default(),
1324 }];
1325 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1326 assert!(result.contains("old\n"));
1328 assert!(result.contains("new\n"));
1329 }
1330
1331 #[test]
1332 fn no_inline_attr_no_config_falls_back_to_default() {
1333 let dir = setup_project();
1335 let doc_path = dir.path().join("test.md");
1336 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1337 std::fs::write(&doc_path, doc).unwrap();
1338
1339 let patches = vec![PatchBlock {
1340 name: "exchange".to_string(),
1341 content: "new\n".to_string(),
1342 attrs: Default::default(),
1343 }];
1344 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1345 assert!(result.contains("old\n"));
1347 assert!(result.contains("new\n"));
1348 }
1349
1350 #[test]
1351 fn inline_patch_attr_overrides_config() {
1352 let dir = setup_project();
1354 let doc_path = dir.path().join("test.md");
1355 std::fs::write(
1356 dir.path().join(".agent-doc/config.toml"),
1357 "[components.status]\npatch = \"append\"\n",
1358 ).unwrap();
1359 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1360 std::fs::write(&doc_path, doc).unwrap();
1361
1362 let patches = vec![PatchBlock {
1363 name: "status".to_string(),
1364 content: "new\n".to_string(),
1365 attrs: Default::default(),
1366 }];
1367 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1368 assert!(result.contains("new\n"));
1369 assert!(!result.contains("old\n"));
1370 }
1371
1372 #[test]
1373 fn inline_patch_attr_overrides_mode_attr() {
1374 let dir = setup_project();
1376 let doc_path = dir.path().join("test.md");
1377 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1378 std::fs::write(&doc_path, doc).unwrap();
1379
1380 let patches = vec![PatchBlock {
1381 name: "exchange".to_string(),
1382 content: "new\n".to_string(),
1383 attrs: Default::default(),
1384 }];
1385 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1386 assert!(result.contains("new\n"));
1387 assert!(!result.contains("old\n"));
1388 }
1389
1390 #[test]
1391 fn toml_patch_key_works() {
1392 let dir = setup_project();
1394 let doc_path = dir.path().join("test.md");
1395 std::fs::write(
1396 dir.path().join(".agent-doc/config.toml"),
1397 "[components.status]\npatch = \"append\"\n",
1398 ).unwrap();
1399 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1400 std::fs::write(&doc_path, doc).unwrap();
1401
1402 let patches = vec![PatchBlock {
1403 name: "status".to_string(),
1404 content: "new\n".to_string(),
1405 attrs: Default::default(),
1406 }];
1407 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1408 assert!(result.contains("old\n"));
1409 assert!(result.contains("new\n"));
1410 }
1411
1412 #[test]
1413 fn stream_override_beats_inline_attr() {
1414 let dir = setup_project();
1416 let doc_path = dir.path().join("test.md");
1417 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1418 std::fs::write(&doc_path, doc).unwrap();
1419
1420 let patches = vec![PatchBlock {
1421 name: "exchange".to_string(),
1422 content: "new\n".to_string(),
1423 attrs: Default::default(),
1424 }];
1425 let mut overrides = std::collections::HashMap::new();
1426 overrides.insert("exchange".to_string(), "replace".to_string());
1427 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1428 assert!(result.contains("new\n"));
1430 assert!(!result.contains("old\n"));
1431 }
1432
1433 #[test]
1434 fn apply_patches_ignores_component_tags_in_code_blocks() {
1435 let dir = setup_project();
1438 let doc_path = dir.path().join("test.md");
1439 let doc = "\
1440# Scaffold Guide
1441
1442Here is an example of a component:
1443
1444```markdown
1445<!-- agent:status -->
1446example scaffold content
1447<!-- /agent:status -->
1448```
1449
1450<!-- agent:status -->
1451real status content
1452<!-- /agent:status -->
1453";
1454 std::fs::write(&doc_path, doc).unwrap();
1455
1456 let patches = vec![PatchBlock {
1457 name: "status".to_string(),
1458 content: "patched status\n".to_string(),
1459 attrs: Default::default(),
1460 }];
1461 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1462
1463 assert!(result.contains("patched status\n"), "real component should receive the patch");
1465 assert!(result.contains("example scaffold content"), "code block content should be preserved");
1467 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1469 }
1470
1471 #[test]
1472 fn unmatched_content_uses_boundary_marker() {
1473 let dir = setup_project();
1474 let file = dir.path().join("test.md");
1475 let doc = concat!(
1476 "---\nagent_doc_format: template\n---\n",
1477 "<!-- agent:exchange patch=append -->\n",
1478 "User prompt here.\n",
1479 "<!-- agent:boundary:test-uuid-123 -->\n",
1480 "<!-- /agent:exchange -->\n",
1481 );
1482 std::fs::write(&file, doc).unwrap();
1483
1484 let patches = vec![];
1486 let unmatched = "### Re: Response\n\nResponse content here.\n";
1487
1488 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1489
1490 let prompt_pos = result.find("User prompt here.").unwrap();
1492 let response_pos = result.find("### Re: Response").unwrap();
1493 assert!(
1494 response_pos > prompt_pos,
1495 "response should appear after the user prompt (boundary insertion)"
1496 );
1497
1498 assert!(
1500 !result.contains("test-uuid-123"),
1501 "boundary marker should be consumed after insertion"
1502 );
1503 }
1504
1505 #[test]
1506 fn explicit_patch_uses_boundary_marker() {
1507 let dir = setup_project();
1508 let file = dir.path().join("test.md");
1509 let doc = concat!(
1510 "---\nagent_doc_format: template\n---\n",
1511 "<!-- agent:exchange patch=append -->\n",
1512 "User prompt here.\n",
1513 "<!-- agent:boundary:patch-uuid-456 -->\n",
1514 "<!-- /agent:exchange -->\n",
1515 );
1516 std::fs::write(&file, doc).unwrap();
1517
1518 let patches = vec![PatchBlock {
1520 name: "exchange".to_string(),
1521 content: "### Re: Response\n\nResponse content.\n".to_string(),
1522 attrs: Default::default(),
1523 }];
1524
1525 let result = apply_patches(doc, &patches, "", &file).unwrap();
1526
1527 let prompt_pos = result.find("User prompt here.").unwrap();
1529 let response_pos = result.find("### Re: Response").unwrap();
1530 assert!(
1531 response_pos > prompt_pos,
1532 "response should appear after user prompt"
1533 );
1534
1535 assert!(
1537 !result.contains("patch-uuid-456"),
1538 "boundary marker should be consumed by explicit patch"
1539 );
1540 }
1541
1542 #[test]
1543 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1544 let dir = setup_project();
1547 let file = dir.path().join("test.md");
1548 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1550 std::fs::write(&file, doc).unwrap();
1551
1552 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1553 let (patches, unmatched) = parse_patches(response).unwrap();
1554 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1555
1556 assert!(
1558 result.contains("<!-- agent:boundary:"),
1559 "boundary must be re-inserted even when original doc had no boundary: {result}"
1560 );
1561 }
1562
1563 #[test]
1564 fn boundary_survives_multiple_cycles() {
1565 let dir = setup_project();
1567 let file = dir.path().join("test.md");
1568 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1569 std::fs::write(&file, doc).unwrap();
1570
1571 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1573 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1574 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1575 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1576
1577 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1579 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1580 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1581 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1582 }
1583
1584 #[test]
1585 fn remove_all_boundaries_skips_code_blocks() {
1586 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1587 let result = remove_all_boundaries(doc);
1588 assert!(
1590 result.contains("<!-- agent:boundary:fake-id -->"),
1591 "boundary inside code block must be preserved: {result}"
1592 );
1593 assert!(
1595 !result.contains("<!-- agent:boundary:real-id -->"),
1596 "boundary outside code block must be removed: {result}"
1597 );
1598 }
1599
1600 #[test]
1601 fn reposition_boundary_moves_to_end() {
1602 let doc = "\
1603<!-- agent:exchange -->
1604Previous response.
1605<!-- agent:boundary:old-id -->
1606User prompt here.
1607<!-- /agent:exchange -->";
1608 let result = reposition_boundary_to_end(doc);
1609 assert!(!result.contains("old-id"), "old boundary should be removed");
1611 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1613 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1615 let prompt_pos = result.find("User prompt here.").unwrap();
1616 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1617 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1618 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1619 }
1620
1621 #[test]
1622 fn reposition_boundary_no_exchange_unchanged() {
1623 let doc = "\
1624<!-- agent:output -->
1625Some content.
1626<!-- /agent:output -->";
1627 let result = reposition_boundary_to_end(doc);
1628 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1629 }
1630
1631 #[test]
1632 fn reposition_appends_head_to_last_re_heading() {
1633 let doc = "\
1637<!-- agent:exchange -->
1638### Re: older (HEAD)
1639old body
1640### Re: newer
1641new body
1642<!-- /agent:exchange -->";
1643 let result = reposition_boundary_to_end(doc);
1644 assert!(
1645 !result.contains("### Re: older (HEAD)"),
1646 "stale (HEAD) on prior heading must be stripped; got:\n{result}"
1647 );
1648 assert!(
1649 result.contains("### Re: older\n"),
1650 "older heading must remain (without HEAD); got:\n{result}"
1651 );
1652 assert!(
1653 result.contains("### Re: newer (HEAD)"),
1654 "latest heading must get (HEAD); got:\n{result}"
1655 );
1656 assert_eq!(
1657 result.matches("(HEAD)").count(),
1658 1,
1659 "exactly one (HEAD) in result; got:\n{result}"
1660 );
1661 }
1662
1663 #[test]
1664 fn reposition_head_annotation_no_re_heading_unchanged() {
1665 let doc = "\
1667<!-- agent:exchange -->
1668User text with no response headings.
1669<!-- /agent:exchange -->";
1670 let result = reposition_boundary_to_end(doc);
1671 assert!(!result.contains("(HEAD)"), "no heading → no (HEAD); got:\n{result}");
1672 }
1673
1674 #[test]
1675 fn reposition_head_annotation_skips_code_fence() {
1676 let doc = "\
1678<!-- agent:exchange -->
1679### Re: real heading
1680```markdown
1681### Re: fake heading in code fence
1682```
1683<!-- /agent:exchange -->";
1684 let result = reposition_boundary_to_end(doc);
1685 assert!(
1686 result.contains("### Re: real heading (HEAD)"),
1687 "real heading outside fence gets (HEAD); got:\n{result}"
1688 );
1689 assert!(
1690 result.contains("### Re: fake heading in code fence\n"),
1691 "fenced heading must be untouched; got:\n{result}"
1692 );
1693 assert_eq!(
1694 result.matches("(HEAD)").count(),
1695 1,
1696 "exactly one (HEAD) — fenced heading ignored; got:\n{result}"
1697 );
1698 }
1699
1700 #[test]
1701 fn reposition_with_baseline_marks_all_new_re_headings() {
1702 let doc = "\
1706<!-- agent:exchange -->
1707### Re: old-1
1708body a
1709### Re: old-2 (HEAD)
1710body b
1711### Re: new-1
1712body c
1713### Re: new-2
1714body d
1715<!-- /agent:exchange -->";
1716 let mut baseline = std::collections::HashSet::new();
1719 baseline.insert("### Re: old-1".to_string());
1720 baseline.insert("### Re: old-2".to_string());
1721
1722 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1723
1724 assert!(result.contains("### Re: old-1\n"), "old-1 must not have (HEAD); got:\n{result}");
1726 assert!(result.contains("### Re: old-2\n"), "old-2 must not have (HEAD); got:\n{result}");
1727 assert!(result.contains("### Re: new-1 (HEAD)"), "new-1 must get (HEAD); got:\n{result}");
1729 assert!(result.contains("### Re: new-2 (HEAD)"), "new-2 must get (HEAD); got:\n{result}");
1730 assert_eq!(
1732 result.matches("(HEAD)").count(),
1733 2,
1734 "exactly two (HEAD) markers; got:\n{result}"
1735 );
1736 }
1737
1738 #[test]
1739 fn reposition_with_empty_baseline_marks_every_re_heading() {
1740 let doc = "\
1743<!-- agent:exchange -->
1744### Re: first
1745a
1746### Re: second
1747b
1748<!-- /agent:exchange -->";
1749 let baseline: std::collections::HashSet<String> = std::collections::HashSet::new();
1750 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1751 assert!(result.contains("### Re: first (HEAD)"), "first gets (HEAD); got:\n{result}");
1752 assert!(result.contains("### Re: second (HEAD)"), "second gets (HEAD); got:\n{result}");
1753 assert_eq!(
1754 result.matches("(HEAD)").count(),
1755 2,
1756 "exactly two (HEAD) markers; got:\n{result}"
1757 );
1758 }
1759
1760 #[test]
1761 fn exchange_baseline_headings_extracts_stripped_re_lines() {
1762 let doc = "\
1763<!-- agent:exchange -->
1764### Re: one (HEAD)
1765body
1766### Re: two
1767more body
1768### Not a Re heading
1769body
1770<!-- /agent:exchange -->";
1771 let set = exchange_baseline_headings(doc);
1772 assert!(set.contains("### Re: one"), "stripped one present; got: {set:?}");
1773 assert!(set.contains("### Re: two"), "two present; got: {set:?}");
1774 assert_eq!(set.len(), 2, "only Re: headings; got: {set:?}");
1775 }
1776
1777 #[test]
1778 fn exchange_baseline_headings_normalizes_leading_whitespace() {
1779 let doc = "\
1782<!-- agent:exchange -->
1783 ### Re: indented
1784body
1785### Re: flush
1786more
1787<!-- /agent:exchange -->";
1788 let set = exchange_baseline_headings(doc);
1789 assert!(set.contains("### Re: indented"), "indented entry normalized; got: {set:?}");
1790 assert!(set.contains("### Re: flush"), "flush entry present; got: {set:?}");
1791 }
1792
1793 #[test]
1794 fn reposition_with_baseline_matches_indented_heading() {
1795 let doc = "\
1803<!-- agent:exchange -->
1804 ### Re: foo
1805body
1806### Re: bar (HEAD)
1807body2
1808<!-- /agent:exchange -->";
1809 let mut baseline = std::collections::HashSet::new();
1810 baseline.insert("### Re: foo".to_string());
1811 baseline.insert("### Re: bar".to_string());
1812 let result =
1813 reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1814 assert!(
1819 result.contains(" ### Re: foo\n"),
1820 "indented heading must remain unmarked; got:\n{result}"
1821 );
1822 assert!(
1823 result.contains("### Re: bar (HEAD)"),
1824 "last heading gets fallback (HEAD) marker; got:\n{result}"
1825 );
1826 assert_eq!(
1827 result.matches("(HEAD)").count(),
1828 1,
1829 "exactly one (HEAD) via fallback; got:\n{result}"
1830 );
1831 }
1832
1833 #[test]
1834 fn baseline_filter_empty_falls_back_to_last_heading() {
1835 let doc = "\
1840<!-- agent:exchange -->
1841### Re: older
1842body
1843### Re: newer (HEAD)
1844more
1845<!-- /agent:exchange -->";
1846 let mut baseline = std::collections::HashSet::new();
1847 baseline.insert("### Re: older".to_string());
1848 baseline.insert("### Re: newer".to_string());
1849 let result =
1850 reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1851 assert!(
1852 result.contains("### Re: newer (HEAD)"),
1853 "last heading retains (HEAD) via fallback; got:\n{result}"
1854 );
1855 assert!(
1856 result.contains("### Re: older\n"),
1857 "older heading remains unmarked; got:\n{result}"
1858 );
1859 assert_eq!(
1860 result.matches("(HEAD)").count(),
1861 1,
1862 "exactly one (HEAD) marker after fallback; got:\n{result}"
1863 );
1864 }
1865
1866 #[test]
1867 fn reposition_head_annotation_strips_multiple_stale() {
1868 let doc = "\
1870<!-- agent:exchange -->
1871### Re: one (HEAD)
1872a
1873### Re: two (HEAD)
1874b
1875### Re: three
1876c
1877<!-- /agent:exchange -->";
1878 let result = reposition_boundary_to_end(doc);
1879 assert_eq!(
1880 result.matches("(HEAD)").count(),
1881 1,
1882 "exactly one (HEAD) after reposition; got:\n{result}"
1883 );
1884 assert!(result.contains("### Re: three (HEAD)"));
1885 assert!(result.contains("### Re: one\n"));
1886 assert!(result.contains("### Re: two\n"));
1887 }
1888
1889 #[test]
1890 fn max_lines_inline_attr_trims_content() {
1891 let dir = setup_project();
1892 let doc_path = dir.path().join("test.md");
1893 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1894 std::fs::write(&doc_path, doc).unwrap();
1895
1896 let patches = vec![PatchBlock {
1897 name: "log".to_string(),
1898 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1899 attrs: Default::default(),
1900 }];
1901 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1902 assert!(!result.contains("line1"));
1903 assert!(!result.contains("line2"));
1904 assert!(result.contains("line3"));
1905 assert!(result.contains("line4"));
1906 assert!(result.contains("line5"));
1907 }
1908
1909 #[test]
1910 fn max_lines_noop_when_under_limit() {
1911 let dir = setup_project();
1912 let doc_path = dir.path().join("test.md");
1913 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1914 std::fs::write(&doc_path, doc).unwrap();
1915
1916 let patches = vec![PatchBlock {
1917 name: "log".to_string(),
1918 content: "line1\nline2\n".to_string(),
1919 attrs: Default::default(),
1920 }];
1921 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1922 assert!(result.contains("line1"));
1923 assert!(result.contains("line2"));
1924 }
1925
1926 #[test]
1927 fn max_lines_from_components_toml() {
1928 let dir = setup_project();
1929 let doc_path = dir.path().join("test.md");
1930 std::fs::write(
1931 dir.path().join(".agent-doc/config.toml"),
1932 "[components.log]\npatch = \"replace\"\nmax_lines = 2\n",
1933 )
1934 .unwrap();
1935 let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1936 std::fs::write(&doc_path, doc).unwrap();
1937
1938 let patches = vec![PatchBlock {
1939 name: "log".to_string(),
1940 content: "a\nb\nc\nd\n".to_string(),
1941 attrs: Default::default(),
1942 }];
1943 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1944 assert!(!result.contains("\na\n"));
1945 assert!(!result.contains("\nb\n"));
1946 assert!(result.contains("c"));
1947 assert!(result.contains("d"));
1948 }
1949
1950 #[test]
1951 fn max_lines_inline_beats_toml() {
1952 let dir = setup_project();
1953 let doc_path = dir.path().join("test.md");
1954 std::fs::write(
1955 dir.path().join(".agent-doc/config.toml"),
1956 "[components.log]\nmax_lines = 1\n",
1957 )
1958 .unwrap();
1959 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1960 std::fs::write(&doc_path, doc).unwrap();
1961
1962 let patches = vec![PatchBlock {
1963 name: "log".to_string(),
1964 content: "a\nb\nc\nd\n".to_string(),
1965 attrs: Default::default(),
1966 }];
1967 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1968 assert!(result.contains("b"));
1970 assert!(result.contains("c"));
1971 assert!(result.contains("d"));
1972 }
1973
1974 #[test]
1975 fn parse_patch_with_transfer_source_attr() {
1976 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
1977 let (patches, unmatched) = parse_patches(response).unwrap();
1978 assert_eq!(patches.len(), 1);
1979 assert_eq!(patches[0].name, "exchange");
1980 assert_eq!(patches[0].content, "Transferred content.\n");
1981 assert_eq!(
1982 patches[0].attrs.get("transfer-source"),
1983 Some(&"\"tasks/eval-runner.md\"".to_string())
1984 );
1985 assert!(unmatched.is_empty());
1986 }
1987
1988 #[test]
1989 fn parse_patch_without_attrs() {
1990 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
1991 let (patches, _) = parse_patches(response).unwrap();
1992 assert_eq!(patches.len(), 1);
1993 assert!(patches[0].attrs.is_empty());
1994 }
1995
1996 #[test]
1997 fn parse_patch_with_multiple_attrs() {
1998 let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
1999 let (patches, _) = parse_patches(response).unwrap();
2000 assert_eq!(patches.len(), 1);
2001 assert_eq!(patches[0].name, "output");
2002 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
2003 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
2004 }
2005
2006 #[test]
2007 fn apply_patches_dedup_exchange_adjacent_echo() {
2008 let dir = setup_project();
2012 let doc_path = dir.path().join("test.md");
2013 let doc = "\
2014<!-- agent:exchange patch=append -->
2015❯ How do I configure .mise.toml?
2016<!-- /agent:exchange -->
2017";
2018 std::fs::write(&doc_path, doc).unwrap();
2019
2020 let patches = vec![PatchBlock {
2022 name: "exchange".to_string(),
2023 content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
2024 attrs: Default::default(),
2025 }];
2026 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2027
2028 let count = result.matches("❯ How do I configure .mise.toml?").count();
2029 assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
2030 assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
2031 assert!(result.contains("Use `[env]` section."), "response body should be present");
2032 }
2033
2034 #[test]
2035 fn apply_patches_dedup_preserves_blank_lines() {
2036 let dir = setup_project();
2038 let doc_path = dir.path().join("test.md");
2039 let doc = "\
2040<!-- agent:exchange patch=append -->
2041Previous response.
2042<!-- /agent:exchange -->
2043";
2044 std::fs::write(&doc_path, doc).unwrap();
2045
2046 let patches = vec![PatchBlock {
2047 name: "exchange".to_string(),
2048 content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
2049 attrs: Default::default(),
2050 }];
2051 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2052 assert!(result.contains("Previous response."), "existing content preserved");
2053 assert!(result.contains("### Re: something"), "response heading present");
2054 assert!(result.contains('\n'), "blank lines preserved");
2056 }
2057
2058 #[test]
2059 fn apply_mode_append_strips_leading_overlap() {
2060 let existing = "❯ How do I configure .mise.toml?\n";
2063 let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
2064 let result = apply_mode("append", existing, new_content);
2065 let count = result.matches("❯ How do I configure .mise.toml?").count();
2066 assert_eq!(count, 1, "overlap line should appear exactly once");
2067 assert!(result.contains("### Re: configure"));
2068 }
2069
2070 #[test]
2071 fn strip_trailing_caret_removes_bare_prompt_line() {
2072 let content = "Answer text.\n❯\n";
2073 assert_eq!(strip_trailing_caret_lines(content), "Answer text.\n");
2074 }
2075
2076 #[test]
2077 fn strip_trailing_caret_removes_multiple_trailing_lines() {
2078 let content = "Answer.\n❯\n❯\n";
2079 assert_eq!(strip_trailing_caret_lines(content), "Answer.\n");
2080 }
2081
2082 #[test]
2083 fn strip_trailing_caret_preserves_mid_content_caret() {
2084 let content = "### Re: topic\n\n❯ user question echoed\n\nAnswer.\n";
2086 assert_eq!(strip_trailing_caret_lines(content), content);
2087 }
2088
2089 #[test]
2090 fn strip_trailing_caret_preserves_caret_with_text() {
2091 let content = "Answer.\n❯ follow-up\n";
2093 assert_eq!(strip_trailing_caret_lines(content), content);
2094 }
2095
2096 #[test]
2097 fn strip_trailing_caret_handles_no_trailing_newline() {
2098 let content = "Answer.\n❯";
2099 assert_eq!(strip_trailing_caret_lines(content), "Answer.");
2100 }
2101
2102 #[test]
2103 fn strip_trailing_caret_noop_when_no_caret() {
2104 let content = "Answer.\n";
2105 assert_eq!(strip_trailing_caret_lines(content), content);
2106 }
2107
2108 #[test]
2109 fn apply_patches_strips_trailing_caret_from_exchange() {
2110 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n❯ prior question\n<!-- /agent:exchange -->\n";
2111 let patches = vec![PatchBlock {
2112 name: "exchange".to_string(),
2113 content: "### Re: thing\n\nAnswer.\n❯\n".to_string(),
2114 attrs: Default::default(),
2115 }];
2116 let doc_path = std::path::PathBuf::from("/tmp/test.md");
2117 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2118 let components = component::parse(&result).unwrap();
2120 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2121 let content = exchange.content(&result);
2122 let has_bare_caret_before_boundary = content
2124 .lines()
2125 .collect::<Vec<_>>()
2126 .windows(2)
2127 .any(|w| w[0].trim() == "❯" && w[1].starts_with("<!-- agent:boundary"));
2128 assert!(
2129 !has_bare_caret_before_boundary,
2130 "bare ❯ line must not appear before boundary marker. content:\n{}",
2131 content
2132 );
2133 }
2134
2135 #[test]
2136 fn apply_patches_preserves_caret_in_non_exchange() {
2137 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:notes patch=replace -->\n<!-- /agent:notes -->\n";
2140 let patches = vec![PatchBlock {
2141 name: "notes".to_string(),
2142 content: "note body\n❯\n".to_string(),
2143 attrs: Default::default(),
2144 }];
2145 let doc_path = std::path::PathBuf::from("/tmp/test.md");
2146 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2147 let components = component::parse(&result).unwrap();
2148 let notes = components.iter().find(|c| c.name == "notes").unwrap();
2149 assert!(notes.content(&result).contains("❯"), "non-exchange content retains ❯");
2150 }
2151
2152 #[test]
2153 fn apply_mode_append_no_overlap_unchanged() {
2154 let existing = "Previous content.\n";
2157 let new_content = "### Re: something\n\nAnswer.\n";
2158 let result = apply_mode("append", existing, new_content);
2159 assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
2160 }
2161}