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 let mut orphan_end = close;
260 if orphan_end < len && bytes[orphan_end] == b'\n' {
261 orphan_end += 1;
262 }
263 last_end = orphan_end;
264 pos = orphan_end;
265 continue;
266 }
267
268 pos = close;
269 }
270
271 if last_end < len {
273 let trailing = response[last_end..].trim();
274 if !trailing.is_empty() {
275 if !unmatched.is_empty() {
276 unmatched.push('\n');
277 }
278 unmatched.push_str(trailing);
279 }
280 }
281
282 Ok((patches, unmatched))
283}
284
285pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
294 apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
295}
296
297pub(crate) fn strip_trailing_caret_lines(content: &str) -> String {
309 let trailing_nl = content.ends_with('\n');
310 let mut lines: Vec<&str> = content.split('\n').collect();
311 if trailing_nl {
314 lines.pop();
315 }
316 while let Some(last) = lines.last() {
317 let t = last.trim();
318 if t == "❯" {
319 lines.pop();
320 } else {
321 break;
322 }
323 }
324 let mut out = lines.join("\n");
325 if trailing_nl {
326 out.push('\n');
327 }
328 out
329}
330
331pub fn apply_patches_with_overrides(
334 doc: &str,
335 patches: &[PatchBlock],
336 unmatched: &str,
337 file: &Path,
338 mode_overrides: &std::collections::HashMap<String, String>,
339) -> Result<String> {
340 let summary = file.file_stem().and_then(|s| s.to_str());
345 let mut result = remove_all_boundaries(doc);
346 if let Ok(components) = component::parse(&result)
347 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
348 {
349 let id = crate::new_boundary_id_with_summary(summary);
350 let marker = crate::format_boundary_marker(&id);
351 let content = exchange.content(&result);
352 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
353 result = exchange.replace_content(&result, &new_content);
354 eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
355 }
356
357 let components = component::parse(&result)
359 .context("failed to parse components")?;
360
361 let configs = load_component_configs(file);
363
364 let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
369 let mut overflow = String::new();
370 for patch in patches {
371 if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
372 ops.push((idx, patch));
373 } else {
374 let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
375 eprintln!(
376 "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
377 patch.name,
378 available.join(", ")
379 );
380 if !overflow.is_empty() {
381 overflow.push('\n');
382 }
383 overflow.push_str(&patch.content);
384 }
385 }
386
387 ops.sort_by(|a, b| b.0.cmp(&a.0));
389
390 for (idx, patch) in &ops {
391 let comp = &components[*idx];
392 let mode = mode_overrides.get(&patch.name)
394 .map(|s| s.as_str())
395 .or_else(|| comp.patch_mode())
396 .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
397 .unwrap_or_else(|| default_mode(&patch.name));
398 let patch_content: std::borrow::Cow<'_, str> = if patch.name == "exchange" {
401 std::borrow::Cow::Owned(strip_trailing_caret_lines(&patch.content))
402 } else {
403 std::borrow::Cow::Borrowed(patch.content.as_str())
404 };
405 if mode == "append"
407 && let Some(bid) = find_boundary_in_component(&result, comp)
408 {
409 result = comp.append_with_boundary(&result, &patch_content, &bid);
410 continue;
411 }
412 let new_content = apply_mode(mode, comp.content(&result), &patch_content);
413 result = comp.replace_content(&result, &new_content);
414 }
415
416 let mut all_unmatched = String::new();
418 if !overflow.is_empty() {
419 all_unmatched.push_str(&overflow);
420 }
421 if !unmatched.is_empty() {
422 if !all_unmatched.is_empty() {
423 all_unmatched.push('\n');
424 }
425 all_unmatched.push_str(unmatched);
426 }
427
428 if !all_unmatched.is_empty() {
430 let components = component::parse(&result)
432 .context("failed to re-parse components after patching")?;
433
434 if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
435 let stripped = if output_comp.name == "exchange" {
438 strip_trailing_caret_lines(&all_unmatched)
439 } else {
440 all_unmatched.clone()
441 };
442 let unmatched = &stripped;
443 if let Some(bid) = find_boundary_in_component(&result, output_comp) {
445 eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
446 result = output_comp.append_with_boundary(&result, unmatched, &bid);
447 } else {
448 let existing = output_comp.content(&result);
450 let new_content = if existing.trim().is_empty() {
451 format!("{}\n", unmatched)
452 } else {
453 format!("{}{}\n", existing, unmatched)
454 };
455 result = output_comp.replace_content(&result, &new_content);
456 }
457 } else {
458 let stripped = strip_trailing_caret_lines(&all_unmatched);
460 if !result.ends_with('\n') {
461 result.push('\n');
462 }
463 result.push_str("\n<!-- agent:exchange -->\n");
464 result.push_str(&stripped);
465 result.push_str("\n<!-- /agent:exchange -->\n");
466 }
467 }
468
469 result = dedup_exchange_adjacent_lines(&result);
472
473 {
479 let max_lines_configs = load_max_lines_configs(file);
480 'stability: for _ in 0..3 {
481 let Ok(components) = component::parse(&result) else { break };
482 for comp in &components {
483 let max_lines = comp
484 .attrs
485 .get("max_lines")
486 .and_then(|s| s.parse::<usize>().ok())
487 .or_else(|| max_lines_configs.get(&comp.name).copied())
488 .unwrap_or(0);
489 if max_lines > 0 {
490 let content = comp.content(&result);
491 let trimmed = limit_lines(content, max_lines);
492 if trimmed.len() != content.len() {
493 let trimmed = format!("{}\n", trimmed.trim_end());
494 result = comp.replace_content(&result, &trimmed);
495 continue 'stability;
497 }
498 }
499 }
500 break; }
502 }
503
504 {
510 if let Ok(components) = component::parse(&result)
511 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
512 && find_boundary_in_component(&result, exchange).is_none()
513 {
514 let id = uuid::Uuid::new_v4().to_string();
516 let marker = format!("<!-- agent:boundary:{} -->", id);
517 let content = exchange.content(&result);
518 let new_content = format!("{}\n{}\n", content.trim_end(), marker);
519 result = exchange.replace_content(&result, &new_content);
520 eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
521 }
522 }
523
524 Ok(result)
525}
526
527pub fn reposition_boundary_to_end(doc: &str) -> String {
535 reposition_boundary_to_end_with_summary(doc, None)
536}
537
538pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
543 reposition_boundary_to_end_with_baseline(doc, summary, None)
544}
545
546pub fn reposition_boundary_to_end_with_baseline(
555 doc: &str,
556 summary: Option<&str>,
557 baseline_headings: Option<&std::collections::HashSet<String>>,
558) -> String {
559 let mut result = remove_all_boundaries(doc);
560 if let Ok(components) = component::parse(&result)
561 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
562 {
563 let id = crate::new_boundary_id_with_summary(summary);
564 let marker = crate::format_boundary_marker(&id);
565 let content = exchange.content(&result).to_string();
566 let annotated = annotate_re_headings_with_head(&content, baseline_headings);
567 let new_content = format!("{}\n{}\n", annotated.trim_end(), marker);
568 result = exchange.replace_content(&result, &new_content);
569 }
570 result
571}
572
573pub fn exchange_baseline_headings(doc: &str) -> std::collections::HashSet<String> {
581 if let Ok(components) = component::parse(doc)
582 && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
583 {
584 return collect_re_headings(exchange.content(doc));
585 }
586 std::collections::HashSet::new()
587}
588
589fn collect_re_headings(content: &str) -> std::collections::HashSet<String> {
593 let code_ranges = component::find_code_ranges(content);
594 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
595 let mut set = std::collections::HashSet::new();
596 let mut offset = 0usize;
597 for line in content.split_inclusive('\n') {
598 let line_start = offset;
599 offset += line.len();
600 if in_code(line_start) {
601 continue;
602 }
603 let body = line.trim_end_matches('\n').trim_end_matches('\r');
604 let trimmed = body.trim_start();
605 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
606 if hash_count == 0 || hash_count > 6 {
607 continue;
608 }
609 let after_hash = &trimmed[hash_count..];
610 if !after_hash.starts_with(' ') {
611 continue;
612 }
613 if !after_hash.trim_start().starts_with("Re:") {
614 continue;
615 }
616 let stripped = body
617 .trim_start()
618 .trim_end()
619 .trim_end_matches(" (HEAD)")
620 .to_string();
621 set.insert(stripped);
622 }
623 set
624}
625
626pub(crate) fn annotate_re_headings_with_head(
645 content: &str,
646 baseline: Option<&std::collections::HashSet<String>>,
647) -> String {
648 let code_ranges = component::find_code_ranges(content);
649 let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
650
651 let mut lines: Vec<String> = content.split_inclusive('\n').map(|s| s.to_string()).collect();
652 let mut re_indices: Vec<usize> = Vec::new();
653 let mut offset = 0usize;
654
655 for (idx, line) in lines.iter_mut().enumerate() {
656 let line_start = offset;
657 offset += line.len();
658 if in_code(line_start) {
659 continue;
660 }
661 let had_newline = line.ends_with('\n');
662 let body_ref = line.trim_end_matches('\n').trim_end_matches('\r');
663 let trimmed = body_ref.trim_start();
664 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
665 if hash_count == 0 || hash_count > 6 {
666 continue;
667 }
668 let after_hash = &trimmed[hash_count..];
669 if !after_hash.starts_with(' ') {
670 continue;
671 }
672 if !after_hash.trim_start().starts_with("Re:") {
673 continue;
674 }
675 let stripped = body_ref.trim_end().trim_end_matches(" (HEAD)");
677 *line = if had_newline {
678 format!("{stripped}\n")
679 } else {
680 stripped.to_string()
681 };
682 re_indices.push(idx);
683 }
684
685 let mark_indices: Vec<usize> = match baseline {
695 Some(baseline_set) => {
696 let filtered: Vec<usize> = re_indices
697 .iter()
698 .copied()
699 .filter(|&idx| {
700 let line = &lines[idx];
701 let key = line
702 .trim_end_matches('\n')
703 .trim_end_matches('\r')
704 .trim_start()
705 .trim_end();
706 !baseline_set.contains(key)
707 })
708 .collect();
709 if filtered.is_empty() {
710 re_indices.last().copied().into_iter().collect()
711 } else {
712 filtered
713 }
714 }
715 None => re_indices.last().copied().into_iter().collect(),
716 };
717
718 for idx in mark_indices {
719 let line = &lines[idx];
720 let had_newline = line.ends_with('\n');
721 let body = line.trim_end_matches('\n').trim_end_matches('\r');
722 lines[idx] = if had_newline {
723 format!("{body} (HEAD)\n")
724 } else {
725 format!("{body} (HEAD)")
726 };
727 }
728
729 lines.concat()
730}
731
732
733fn remove_all_boundaries(doc: &str) -> String {
736 let prefix = "<!-- agent:boundary:";
737 let suffix = " -->";
738 let code_ranges = component::find_code_ranges(doc);
739 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
740 let mut result = String::with_capacity(doc.len());
741 let mut offset = 0;
742 for line in doc.lines() {
743 let trimmed = line.trim();
744 let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
745 if is_boundary && !in_code(offset) {
746 offset += line.len() + 1; continue;
749 }
750 result.push_str(line);
751 result.push('\n');
752 offset += line.len() + 1;
753 }
754 if !doc.ends_with('\n') && result.ends_with('\n') {
755 result.pop();
756 }
757 result
758}
759
760fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
762 let prefix = "<!-- agent:boundary:";
763 let suffix = " -->";
764 let content_region = &doc[comp.open_end..comp.close_start];
765 let code_ranges = component::find_code_ranges(doc);
766 let mut search_from = 0;
767 while let Some(start) = content_region[search_from..].find(prefix) {
768 let abs_start = comp.open_end + search_from + start;
769 if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
770 search_from += start + prefix.len();
771 continue;
772 }
773 let after_prefix = &content_region[search_from + start + prefix.len()..];
774 if let Some(end) = after_prefix.find(suffix) {
775 return Some(after_prefix[..end].trim().to_string());
776 }
777 break;
778 }
779 None
780}
781
782pub fn template_info(file: &Path) -> Result<TemplateInfo> {
784 let doc = std::fs::read_to_string(file)
785 .with_context(|| format!("failed to read {}", file.display()))?;
786
787 let (fm, _body) = crate::frontmatter::parse(&doc)?;
788 let template_mode = fm.resolve_mode().is_template();
789
790 let components = component::parse(&doc)
791 .with_context(|| format!("failed to parse components in {}", file.display()))?;
792
793 let configs = load_component_configs(file);
794
795 let component_infos: Vec<ComponentInfo> = components
796 .iter()
797 .map(|comp| {
798 let content = comp.content(&doc).to_string();
799 let mode = comp.patch_mode().map(|s| s.to_string())
801 .or_else(|| configs.get(&comp.name).cloned())
802 .unwrap_or_else(|| default_mode(&comp.name).to_string());
803 let line = doc[..comp.open_start].matches('\n').count() + 1;
805 ComponentInfo {
806 name: comp.name.clone(),
807 mode,
808 content,
809 line,
810 max_entries: None, }
812 })
813 .collect();
814
815 Ok(TemplateInfo {
816 template_mode,
817 components: component_infos,
818 })
819}
820
821fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
825 let proj_cfg = load_project_from_doc(file);
826 proj_cfg
827 .components
828 .iter()
829 .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.patch.clone()))
830 .collect()
831}
832
833fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
836 let proj_cfg = load_project_from_doc(file);
837 proj_cfg
838 .components
839 .iter()
840 .filter(|(_, cfg)| cfg.max_lines > 0)
841 .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.max_lines))
842 .collect()
843}
844
845fn load_project_from_doc(file: &Path) -> project_config::ProjectConfig {
847 let start = file.parent().unwrap_or(file);
848 let mut current = start;
849 loop {
850 let candidate = current.join(".agent-doc").join("config.toml");
851 if candidate.exists() {
852 return project_config::load_project_from(&candidate);
853 }
854 match current.parent() {
855 Some(p) if p != current => current = p,
856 _ => break,
857 }
858 }
859 project_config::load_project()
861}
862
863fn default_mode(name: &str) -> &'static str {
866 match name {
867 "exchange" | "findings" => "append",
868 _ => "replace",
869 }
870}
871
872fn limit_lines(content: &str, max_lines: usize) -> String {
874 let lines: Vec<&str> = content.lines().collect();
875 if lines.len() <= max_lines {
876 return content.to_string();
877 }
878 lines[lines.len() - max_lines..].join("\n")
879}
880
881fn dedup_exchange_adjacent_lines(doc: &str) -> String {
890 let Ok(components) = component::parse(doc) else {
891 return doc.to_string();
892 };
893 let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
894 return doc.to_string();
895 };
896 let content = exchange.content(doc);
897 let mut deduped = String::with_capacity(content.len());
898 let mut prev_nonempty: Option<&str> = None;
899 for line in content.lines() {
900 if !line.trim().is_empty() && prev_nonempty == Some(line) {
901 continue;
903 }
904 deduped.push_str(line);
905 deduped.push('\n');
906 if !line.trim().is_empty() {
907 prev_nonempty = Some(line);
908 }
909 }
910 if !content.ends_with('\n') && deduped.ends_with('\n') {
912 deduped.pop();
913 }
914 if deduped == content {
915 return doc.to_string();
916 }
917 exchange.replace_content(doc, &deduped)
918}
919
920fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
922 match mode {
923 "append" => {
924 let stripped = strip_leading_overlap(existing, new_content);
925 format!("{}{}", existing, stripped)
926 }
927 "prepend" => format!("{}{}", new_content, existing),
928 _ => new_content.to_string(), }
930}
931
932fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
937 let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
938 let Some(last) = last_nonempty else {
939 return new_content;
940 };
941 let test = format!("{}\n", last);
942 if new_content.starts_with(test.as_str()) {
943 &new_content[test.len()..]
944 } else {
945 new_content
946 }
947}
948
949#[allow(dead_code)]
950fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
951 let canonical = file.canonicalize().ok()?;
952 let mut dir = canonical.parent()?;
953 loop {
954 if dir.join(".agent-doc").is_dir() {
955 return Some(dir.to_path_buf());
956 }
957 dir = dir.parent()?;
958 }
959}
960
961fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
964 let mut search_start = from;
965 loop {
966 let rel = haystack[search_start..].find(needle)?;
967 let abs = search_start + rel;
968 if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
969 search_start = abs + needle.len();
971 continue;
972 }
973 return Some(abs);
974 }
975}
976
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981 use tempfile::TempDir;
982
983 fn setup_project() -> TempDir {
984 let dir = TempDir::new().unwrap();
985 std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
986 dir
987 }
988
989 #[test]
990 fn parse_single_patch() {
991 let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
992 let (patches, unmatched) = parse_patches(response).unwrap();
993 assert_eq!(patches.len(), 1);
994 assert_eq!(patches[0].name, "status");
995 assert_eq!(patches[0].content, "Build passing.\n");
996 assert!(unmatched.is_empty());
997 }
998
999 #[test]
1000 fn parse_multiple_patches() {
1001 let response = "\
1002<!-- patch:status -->
1003All green.
1004<!-- /patch:status -->
1005
1006<!-- patch:log -->
1007- New entry
1008<!-- /patch:log -->
1009";
1010 let (patches, unmatched) = parse_patches(response).unwrap();
1011 assert_eq!(patches.len(), 2);
1012 assert_eq!(patches[0].name, "status");
1013 assert_eq!(patches[0].content, "All green.\n");
1014 assert_eq!(patches[1].name, "log");
1015 assert_eq!(patches[1].content, "- New entry\n");
1016 assert!(unmatched.is_empty());
1017 }
1018
1019 #[test]
1020 fn parse_with_unmatched_content() {
1021 let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
1022 let (patches, unmatched) = parse_patches(response).unwrap();
1023 assert_eq!(patches.len(), 1);
1024 assert_eq!(patches[0].name, "status");
1025 assert!(unmatched.contains("Some free text."));
1026 assert!(unmatched.contains("Trailing text."));
1027 }
1028
1029 #[test]
1030 fn parse_empty_response() {
1031 let (patches, unmatched) = parse_patches("").unwrap();
1032 assert!(patches.is_empty());
1033 assert!(unmatched.is_empty());
1034 }
1035
1036 #[test]
1037 fn parse_no_patches() {
1038 let response = "Just a plain response with no patch blocks.";
1039 let (patches, unmatched) = parse_patches(response).unwrap();
1040 assert!(patches.is_empty());
1041 assert_eq!(unmatched, "Just a plain response with no patch blocks.");
1042 }
1043
1044 #[test]
1045 fn apply_patches_replace() {
1046 let dir = setup_project();
1047 let doc_path = dir.path().join("test.md");
1048 let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1049 std::fs::write(&doc_path, doc).unwrap();
1050
1051 let patches = vec![PatchBlock {
1052 name: "status".to_string(),
1053 content: "new\n".to_string(),
1054 attrs: Default::default(),
1055 }];
1056 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1057 assert!(result.contains("new\n"));
1058 assert!(!result.contains("\nold\n"));
1059 assert!(result.contains("<!-- agent:status -->"));
1060 }
1061
1062 #[test]
1063 fn apply_patches_unmatched_creates_exchange() {
1064 let dir = setup_project();
1065 let doc_path = dir.path().join("test.md");
1066 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
1067 std::fs::write(&doc_path, doc).unwrap();
1068
1069 let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
1070 assert!(result.contains("<!-- agent:exchange -->"));
1071 assert!(result.contains("Extra info here"));
1072 assert!(result.contains("<!-- /agent:exchange -->"));
1073 }
1074
1075 #[test]
1076 fn apply_patches_unmatched_appends_to_existing_exchange() {
1077 let dir = setup_project();
1078 let doc_path = dir.path().join("test.md");
1079 let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
1080 std::fs::write(&doc_path, doc).unwrap();
1081
1082 let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
1083 assert!(result.contains("previous"));
1084 assert!(result.contains("new stuff"));
1085 assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
1087 }
1088
1089 #[test]
1090 fn apply_patches_missing_component_routes_to_exchange() {
1091 let dir = setup_project();
1092 let doc_path = dir.path().join("test.md");
1093 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
1094 std::fs::write(&doc_path, doc).unwrap();
1095
1096 let patches = vec![PatchBlock {
1097 name: "nonexistent".to_string(),
1098 content: "overflow data\n".to_string(),
1099 attrs: Default::default(),
1100 }];
1101 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1102 assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
1104 assert!(result.contains("previous"), "existing exchange content should be preserved");
1105 }
1106
1107 #[test]
1108 fn apply_patches_missing_component_creates_exchange() {
1109 let dir = setup_project();
1110 let doc_path = dir.path().join("test.md");
1111 let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
1112 std::fs::write(&doc_path, doc).unwrap();
1113
1114 let patches = vec![PatchBlock {
1115 name: "nonexistent".to_string(),
1116 content: "overflow data\n".to_string(),
1117 attrs: Default::default(),
1118 }];
1119 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1120 assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
1122 assert!(result.contains("overflow data"), "overflow content should be in exchange");
1123 }
1124
1125 #[test]
1126 fn is_template_mode_detection() {
1127 assert!(is_template_mode(Some("template")));
1128 assert!(!is_template_mode(Some("append")));
1129 assert!(!is_template_mode(None));
1130 }
1131
1132 #[test]
1133 fn template_info_works() {
1134 let dir = setup_project();
1135 let doc_path = dir.path().join("test.md");
1136 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1137 std::fs::write(&doc_path, doc).unwrap();
1138
1139 let info = template_info(&doc_path).unwrap();
1140 assert!(info.template_mode);
1141 assert_eq!(info.components.len(), 1);
1142 assert_eq!(info.components[0].name, "status");
1143 assert_eq!(info.components[0].content, "content\n");
1144 }
1145
1146 #[test]
1147 fn template_info_legacy_mode_works() {
1148 let dir = setup_project();
1149 let doc_path = dir.path().join("test.md");
1150 let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1151 std::fs::write(&doc_path, doc).unwrap();
1152
1153 let info = template_info(&doc_path).unwrap();
1154 assert!(info.template_mode);
1155 }
1156
1157 #[test]
1158 fn template_info_append_mode() {
1159 let dir = setup_project();
1160 let doc_path = dir.path().join("test.md");
1161 let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
1162 std::fs::write(&doc_path, doc).unwrap();
1163
1164 let info = template_info(&doc_path).unwrap();
1165 assert!(!info.template_mode);
1166 assert!(info.components.is_empty());
1167 }
1168
1169 #[test]
1170 fn parse_patches_ignores_markers_in_fenced_code_block() {
1171 let response = "\
1172<!-- patch:exchange -->
1173Here is how you use component markers:
1174
1175```markdown
1176<!-- agent:exchange -->
1177example content
1178<!-- /agent:exchange -->
1179```
1180
1181<!-- /patch:exchange -->
1182";
1183 let (patches, unmatched) = parse_patches(response).unwrap();
1184 assert_eq!(patches.len(), 1);
1185 assert_eq!(patches[0].name, "exchange");
1186 assert!(patches[0].content.contains("```markdown"));
1187 assert!(patches[0].content.contains("<!-- agent:exchange -->"));
1188 assert!(unmatched.is_empty());
1189 }
1190
1191 #[test]
1192 fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
1193 let response = "\
1195<!-- patch:exchange -->
1196Real content here.
1197
1198```markdown
1199<!-- patch:fake -->
1200This is just an example.
1201<!-- /patch:fake -->
1202```
1203
1204<!-- /patch:exchange -->
1205";
1206 let (patches, unmatched) = parse_patches(response).unwrap();
1207 assert_eq!(patches.len(), 1, "should only find the outer real patch");
1208 assert_eq!(patches[0].name, "exchange");
1209 assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
1210 assert!(unmatched.is_empty());
1211 }
1212
1213 #[test]
1214 fn parse_patches_ignores_markers_in_tilde_fence() {
1215 let response = "\
1216<!-- patch:status -->
1217OK
1218<!-- /patch:status -->
1219
1220~~~
1221<!-- patch:fake -->
1222example
1223<!-- /patch:fake -->
1224~~~
1225";
1226 let (patches, _unmatched) = parse_patches(response).unwrap();
1227 assert_eq!(patches.len(), 1);
1229 assert_eq!(patches[0].name, "status");
1230 }
1231
1232 #[test]
1233 fn parse_patches_ignores_closing_marker_in_code_block() {
1234 let response = "\
1237<!-- patch:exchange -->
1238Example:
1239
1240```
1241<!-- /patch:exchange -->
1242```
1243
1244Real content continues.
1245<!-- /patch:exchange -->
1246";
1247 let (patches, _unmatched) = parse_patches(response).unwrap();
1248 assert_eq!(patches.len(), 1);
1249 assert_eq!(patches[0].name, "exchange");
1250 assert!(patches[0].content.contains("Real content continues."));
1251 }
1252
1253 #[test]
1254 fn parse_patches_normal_markers_still_work() {
1255 let response = "\
1257<!-- patch:status -->
1258All systems go.
1259<!-- /patch:status -->
1260<!-- patch:log -->
1261- Entry 1
1262<!-- /patch:log -->
1263";
1264 let (patches, unmatched) = parse_patches(response).unwrap();
1265 assert_eq!(patches.len(), 2);
1266 assert_eq!(patches[0].name, "status");
1267 assert_eq!(patches[0].content, "All systems go.\n");
1268 assert_eq!(patches[1].name, "log");
1269 assert_eq!(patches[1].content, "- Entry 1\n");
1270 assert!(unmatched.is_empty());
1271 }
1272
1273 #[test]
1274 fn parse_patches_orphaned_opener_does_not_leak_into_unmatched() {
1275 let response = "\
1278Some real content here.
1279<!-- patch:exchange -->
1280This opener has no matching close.
1281";
1282 let (patches, unmatched) = parse_patches(response).unwrap();
1283 assert!(patches.is_empty(), "orphaned opener should not produce a patch");
1284 assert_eq!(
1285 unmatched, "Some real content here.\nThis opener has no matching close.",
1286 "unmatched should contain text before and after the orphaned marker, but not the marker itself"
1287 );
1288 }
1289
1290 #[test]
1291 fn parse_patches_orphaned_opener_between_valid_patches() {
1292 let response = "\
1295<!-- patch:status -->
1296All good.
1297<!-- /patch:status -->
1298Interstitial text.
1299<!-- patch:exchange -->
1300<!-- patch:log -->
1301- Log entry
1302<!-- /patch:log -->
1303";
1304 let (patches, unmatched) = parse_patches(response).unwrap();
1305 assert_eq!(patches.len(), 2);
1306 assert_eq!(patches[0].name, "status");
1307 assert_eq!(patches[1].name, "log");
1308 assert_eq!(unmatched, "Interstitial text.");
1309 }
1310
1311 #[test]
1314 fn inline_attr_mode_overrides_config() {
1315 let dir = setup_project();
1317 let doc_path = dir.path().join("test.md");
1318 std::fs::write(
1320 dir.path().join(".agent-doc/config.toml"),
1321 "[components.status]\npatch = \"append\"\n",
1322 ).unwrap();
1323 let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
1325 std::fs::write(&doc_path, doc).unwrap();
1326
1327 let patches = vec![PatchBlock {
1328 name: "status".to_string(),
1329 content: "new\n".to_string(),
1330 attrs: Default::default(),
1331 }];
1332 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1333 assert!(result.contains("new\n"));
1335 assert!(!result.contains("old\n"));
1336 }
1337
1338 #[test]
1339 fn inline_attr_mode_overrides_default() {
1340 let dir = setup_project();
1342 let doc_path = dir.path().join("test.md");
1343 let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
1344 std::fs::write(&doc_path, doc).unwrap();
1345
1346 let patches = vec![PatchBlock {
1347 name: "exchange".to_string(),
1348 content: "new\n".to_string(),
1349 attrs: Default::default(),
1350 }];
1351 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1352 assert!(result.contains("new\n"));
1353 assert!(!result.contains("old\n"));
1354 }
1355
1356 #[test]
1357 fn no_inline_attr_falls_back_to_config() {
1358 let dir = setup_project();
1360 let doc_path = dir.path().join("test.md");
1361 std::fs::write(
1362 dir.path().join(".agent-doc/config.toml"),
1363 "[components.status]\npatch = \"append\"\n",
1364 ).unwrap();
1365 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1366 std::fs::write(&doc_path, doc).unwrap();
1367
1368 let patches = vec![PatchBlock {
1369 name: "status".to_string(),
1370 content: "new\n".to_string(),
1371 attrs: Default::default(),
1372 }];
1373 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1374 assert!(result.contains("old\n"));
1376 assert!(result.contains("new\n"));
1377 }
1378
1379 #[test]
1380 fn no_inline_attr_no_config_falls_back_to_default() {
1381 let dir = setup_project();
1383 let doc_path = dir.path().join("test.md");
1384 let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1385 std::fs::write(&doc_path, doc).unwrap();
1386
1387 let patches = vec![PatchBlock {
1388 name: "exchange".to_string(),
1389 content: "new\n".to_string(),
1390 attrs: Default::default(),
1391 }];
1392 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1393 assert!(result.contains("old\n"));
1395 assert!(result.contains("new\n"));
1396 }
1397
1398 #[test]
1399 fn inline_patch_attr_overrides_config() {
1400 let dir = setup_project();
1402 let doc_path = dir.path().join("test.md");
1403 std::fs::write(
1404 dir.path().join(".agent-doc/config.toml"),
1405 "[components.status]\npatch = \"append\"\n",
1406 ).unwrap();
1407 let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1408 std::fs::write(&doc_path, doc).unwrap();
1409
1410 let patches = vec![PatchBlock {
1411 name: "status".to_string(),
1412 content: "new\n".to_string(),
1413 attrs: Default::default(),
1414 }];
1415 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1416 assert!(result.contains("new\n"));
1417 assert!(!result.contains("old\n"));
1418 }
1419
1420 #[test]
1421 fn inline_patch_attr_overrides_mode_attr() {
1422 let dir = setup_project();
1424 let doc_path = dir.path().join("test.md");
1425 let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1426 std::fs::write(&doc_path, doc).unwrap();
1427
1428 let patches = vec![PatchBlock {
1429 name: "exchange".to_string(),
1430 content: "new\n".to_string(),
1431 attrs: Default::default(),
1432 }];
1433 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1434 assert!(result.contains("new\n"));
1435 assert!(!result.contains("old\n"));
1436 }
1437
1438 #[test]
1439 fn toml_patch_key_works() {
1440 let dir = setup_project();
1442 let doc_path = dir.path().join("test.md");
1443 std::fs::write(
1444 dir.path().join(".agent-doc/config.toml"),
1445 "[components.status]\npatch = \"append\"\n",
1446 ).unwrap();
1447 let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1448 std::fs::write(&doc_path, doc).unwrap();
1449
1450 let patches = vec![PatchBlock {
1451 name: "status".to_string(),
1452 content: "new\n".to_string(),
1453 attrs: Default::default(),
1454 }];
1455 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1456 assert!(result.contains("old\n"));
1457 assert!(result.contains("new\n"));
1458 }
1459
1460 #[test]
1461 fn stream_override_beats_inline_attr() {
1462 let dir = setup_project();
1464 let doc_path = dir.path().join("test.md");
1465 let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1466 std::fs::write(&doc_path, doc).unwrap();
1467
1468 let patches = vec![PatchBlock {
1469 name: "exchange".to_string(),
1470 content: "new\n".to_string(),
1471 attrs: Default::default(),
1472 }];
1473 let mut overrides = std::collections::HashMap::new();
1474 overrides.insert("exchange".to_string(), "replace".to_string());
1475 let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1476 assert!(result.contains("new\n"));
1478 assert!(!result.contains("old\n"));
1479 }
1480
1481 #[test]
1482 fn apply_patches_ignores_component_tags_in_code_blocks() {
1483 let dir = setup_project();
1486 let doc_path = dir.path().join("test.md");
1487 let doc = "\
1488# Scaffold Guide
1489
1490Here is an example of a component:
1491
1492```markdown
1493<!-- agent:status -->
1494example scaffold content
1495<!-- /agent:status -->
1496```
1497
1498<!-- agent:status -->
1499real status content
1500<!-- /agent:status -->
1501";
1502 std::fs::write(&doc_path, doc).unwrap();
1503
1504 let patches = vec![PatchBlock {
1505 name: "status".to_string(),
1506 content: "patched status\n".to_string(),
1507 attrs: Default::default(),
1508 }];
1509 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1510
1511 assert!(result.contains("patched status\n"), "real component should receive the patch");
1513 assert!(result.contains("example scaffold content"), "code block content should be preserved");
1515 assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1517 }
1518
1519 #[test]
1520 fn unmatched_content_uses_boundary_marker() {
1521 let dir = setup_project();
1522 let file = dir.path().join("test.md");
1523 let doc = concat!(
1524 "---\nagent_doc_format: template\n---\n",
1525 "<!-- agent:exchange patch=append -->\n",
1526 "User prompt here.\n",
1527 "<!-- agent:boundary:test-uuid-123 -->\n",
1528 "<!-- /agent:exchange -->\n",
1529 );
1530 std::fs::write(&file, doc).unwrap();
1531
1532 let patches = vec![];
1534 let unmatched = "### Re: Response\n\nResponse content here.\n";
1535
1536 let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1537
1538 let prompt_pos = result.find("User prompt here.").unwrap();
1540 let response_pos = result.find("### Re: Response").unwrap();
1541 assert!(
1542 response_pos > prompt_pos,
1543 "response should appear after the user prompt (boundary insertion)"
1544 );
1545
1546 assert!(
1548 !result.contains("test-uuid-123"),
1549 "boundary marker should be consumed after insertion"
1550 );
1551 }
1552
1553 #[test]
1554 fn explicit_patch_uses_boundary_marker() {
1555 let dir = setup_project();
1556 let file = dir.path().join("test.md");
1557 let doc = concat!(
1558 "---\nagent_doc_format: template\n---\n",
1559 "<!-- agent:exchange patch=append -->\n",
1560 "User prompt here.\n",
1561 "<!-- agent:boundary:patch-uuid-456 -->\n",
1562 "<!-- /agent:exchange -->\n",
1563 );
1564 std::fs::write(&file, doc).unwrap();
1565
1566 let patches = vec![PatchBlock {
1568 name: "exchange".to_string(),
1569 content: "### Re: Response\n\nResponse content.\n".to_string(),
1570 attrs: Default::default(),
1571 }];
1572
1573 let result = apply_patches(doc, &patches, "", &file).unwrap();
1574
1575 let prompt_pos = result.find("User prompt here.").unwrap();
1577 let response_pos = result.find("### Re: Response").unwrap();
1578 assert!(
1579 response_pos > prompt_pos,
1580 "response should appear after user prompt"
1581 );
1582
1583 assert!(
1585 !result.contains("patch-uuid-456"),
1586 "boundary marker should be consumed by explicit patch"
1587 );
1588 }
1589
1590 #[test]
1591 fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1592 let dir = setup_project();
1595 let file = dir.path().join("test.md");
1596 let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1598 std::fs::write(&file, doc).unwrap();
1599
1600 let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1601 let (patches, unmatched) = parse_patches(response).unwrap();
1602 let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1603
1604 assert!(
1606 result.contains("<!-- agent:boundary:"),
1607 "boundary must be re-inserted even when original doc had no boundary: {result}"
1608 );
1609 }
1610
1611 #[test]
1612 fn boundary_survives_multiple_cycles() {
1613 let dir = setup_project();
1615 let file = dir.path().join("test.md");
1616 let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1617 std::fs::write(&file, doc).unwrap();
1618
1619 let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1621 let (patches1, unmatched1) = parse_patches(response1).unwrap();
1622 let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1623 assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1624
1625 let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1627 let (patches2, unmatched2) = parse_patches(response2).unwrap();
1628 let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1629 assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1630 }
1631
1632 #[test]
1633 fn remove_all_boundaries_skips_code_blocks() {
1634 let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1635 let result = remove_all_boundaries(doc);
1636 assert!(
1638 result.contains("<!-- agent:boundary:fake-id -->"),
1639 "boundary inside code block must be preserved: {result}"
1640 );
1641 assert!(
1643 !result.contains("<!-- agent:boundary:real-id -->"),
1644 "boundary outside code block must be removed: {result}"
1645 );
1646 }
1647
1648 #[test]
1649 fn reposition_boundary_moves_to_end() {
1650 let doc = "\
1651<!-- agent:exchange -->
1652Previous response.
1653<!-- agent:boundary:old-id -->
1654User prompt here.
1655<!-- /agent:exchange -->";
1656 let result = reposition_boundary_to_end(doc);
1657 assert!(!result.contains("old-id"), "old boundary should be removed");
1659 assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1661 let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1663 let prompt_pos = result.find("User prompt here.").unwrap();
1664 let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1665 assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1666 assert!(boundary_pos < close_pos, "boundary should be before close tag");
1667 }
1668
1669 #[test]
1670 fn reposition_boundary_no_exchange_unchanged() {
1671 let doc = "\
1672<!-- agent:output -->
1673Some content.
1674<!-- /agent:output -->";
1675 let result = reposition_boundary_to_end(doc);
1676 assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1677 }
1678
1679 #[test]
1680 fn reposition_appends_head_to_last_re_heading() {
1681 let doc = "\
1685<!-- agent:exchange -->
1686### Re: older (HEAD)
1687old body
1688### Re: newer
1689new body
1690<!-- /agent:exchange -->";
1691 let result = reposition_boundary_to_end(doc);
1692 assert!(
1693 !result.contains("### Re: older (HEAD)"),
1694 "stale (HEAD) on prior heading must be stripped; got:\n{result}"
1695 );
1696 assert!(
1697 result.contains("### Re: older\n"),
1698 "older heading must remain (without HEAD); got:\n{result}"
1699 );
1700 assert!(
1701 result.contains("### Re: newer (HEAD)"),
1702 "latest heading must get (HEAD); got:\n{result}"
1703 );
1704 assert_eq!(
1705 result.matches("(HEAD)").count(),
1706 1,
1707 "exactly one (HEAD) in result; got:\n{result}"
1708 );
1709 }
1710
1711 #[test]
1712 fn reposition_head_annotation_no_re_heading_unchanged() {
1713 let doc = "\
1715<!-- agent:exchange -->
1716User text with no response headings.
1717<!-- /agent:exchange -->";
1718 let result = reposition_boundary_to_end(doc);
1719 assert!(!result.contains("(HEAD)"), "no heading → no (HEAD); got:\n{result}");
1720 }
1721
1722 #[test]
1723 fn reposition_head_annotation_skips_code_fence() {
1724 let doc = "\
1726<!-- agent:exchange -->
1727### Re: real heading
1728```markdown
1729### Re: fake heading in code fence
1730```
1731<!-- /agent:exchange -->";
1732 let result = reposition_boundary_to_end(doc);
1733 assert!(
1734 result.contains("### Re: real heading (HEAD)"),
1735 "real heading outside fence gets (HEAD); got:\n{result}"
1736 );
1737 assert!(
1738 result.contains("### Re: fake heading in code fence\n"),
1739 "fenced heading must be untouched; got:\n{result}"
1740 );
1741 assert_eq!(
1742 result.matches("(HEAD)").count(),
1743 1,
1744 "exactly one (HEAD) — fenced heading ignored; got:\n{result}"
1745 );
1746 }
1747
1748 #[test]
1749 fn reposition_with_baseline_marks_all_new_re_headings() {
1750 let doc = "\
1754<!-- agent:exchange -->
1755### Re: old-1
1756body a
1757### Re: old-2 (HEAD)
1758body b
1759### Re: new-1
1760body c
1761### Re: new-2
1762body d
1763<!-- /agent:exchange -->";
1764 let mut baseline = std::collections::HashSet::new();
1767 baseline.insert("### Re: old-1".to_string());
1768 baseline.insert("### Re: old-2".to_string());
1769
1770 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1771
1772 assert!(result.contains("### Re: old-1\n"), "old-1 must not have (HEAD); got:\n{result}");
1774 assert!(result.contains("### Re: old-2\n"), "old-2 must not have (HEAD); got:\n{result}");
1775 assert!(result.contains("### Re: new-1 (HEAD)"), "new-1 must get (HEAD); got:\n{result}");
1777 assert!(result.contains("### Re: new-2 (HEAD)"), "new-2 must get (HEAD); got:\n{result}");
1778 assert_eq!(
1780 result.matches("(HEAD)").count(),
1781 2,
1782 "exactly two (HEAD) markers; got:\n{result}"
1783 );
1784 }
1785
1786 #[test]
1787 fn reposition_with_empty_baseline_marks_every_re_heading() {
1788 let doc = "\
1791<!-- agent:exchange -->
1792### Re: first
1793a
1794### Re: second
1795b
1796<!-- /agent:exchange -->";
1797 let baseline: std::collections::HashSet<String> = std::collections::HashSet::new();
1798 let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1799 assert!(result.contains("### Re: first (HEAD)"), "first gets (HEAD); got:\n{result}");
1800 assert!(result.contains("### Re: second (HEAD)"), "second gets (HEAD); got:\n{result}");
1801 assert_eq!(
1802 result.matches("(HEAD)").count(),
1803 2,
1804 "exactly two (HEAD) markers; got:\n{result}"
1805 );
1806 }
1807
1808 #[test]
1809 fn exchange_baseline_headings_extracts_stripped_re_lines() {
1810 let doc = "\
1811<!-- agent:exchange -->
1812### Re: one (HEAD)
1813body
1814### Re: two
1815more body
1816### Not a Re heading
1817body
1818<!-- /agent:exchange -->";
1819 let set = exchange_baseline_headings(doc);
1820 assert!(set.contains("### Re: one"), "stripped one present; got: {set:?}");
1821 assert!(set.contains("### Re: two"), "two present; got: {set:?}");
1822 assert_eq!(set.len(), 2, "only Re: headings; got: {set:?}");
1823 }
1824
1825 #[test]
1826 fn exchange_baseline_headings_normalizes_leading_whitespace() {
1827 let doc = "\
1830<!-- agent:exchange -->
1831 ### Re: indented
1832body
1833### Re: flush
1834more
1835<!-- /agent:exchange -->";
1836 let set = exchange_baseline_headings(doc);
1837 assert!(set.contains("### Re: indented"), "indented entry normalized; got: {set:?}");
1838 assert!(set.contains("### Re: flush"), "flush entry present; got: {set:?}");
1839 }
1840
1841 #[test]
1842 fn reposition_with_baseline_matches_indented_heading() {
1843 let doc = "\
1851<!-- agent:exchange -->
1852 ### Re: foo
1853body
1854### Re: bar (HEAD)
1855body2
1856<!-- /agent:exchange -->";
1857 let mut baseline = std::collections::HashSet::new();
1858 baseline.insert("### Re: foo".to_string());
1859 baseline.insert("### Re: bar".to_string());
1860 let result =
1861 reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1862 assert!(
1867 result.contains(" ### Re: foo\n"),
1868 "indented heading must remain unmarked; got:\n{result}"
1869 );
1870 assert!(
1871 result.contains("### Re: bar (HEAD)"),
1872 "last heading gets fallback (HEAD) marker; got:\n{result}"
1873 );
1874 assert_eq!(
1875 result.matches("(HEAD)").count(),
1876 1,
1877 "exactly one (HEAD) via fallback; got:\n{result}"
1878 );
1879 }
1880
1881 #[test]
1882 fn baseline_filter_empty_falls_back_to_last_heading() {
1883 let doc = "\
1888<!-- agent:exchange -->
1889### Re: older
1890body
1891### Re: newer (HEAD)
1892more
1893<!-- /agent:exchange -->";
1894 let mut baseline = std::collections::HashSet::new();
1895 baseline.insert("### Re: older".to_string());
1896 baseline.insert("### Re: newer".to_string());
1897 let result =
1898 reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1899 assert!(
1900 result.contains("### Re: newer (HEAD)"),
1901 "last heading retains (HEAD) via fallback; got:\n{result}"
1902 );
1903 assert!(
1904 result.contains("### Re: older\n"),
1905 "older heading remains unmarked; got:\n{result}"
1906 );
1907 assert_eq!(
1908 result.matches("(HEAD)").count(),
1909 1,
1910 "exactly one (HEAD) marker after fallback; got:\n{result}"
1911 );
1912 }
1913
1914 #[test]
1915 fn reposition_head_annotation_strips_multiple_stale() {
1916 let doc = "\
1918<!-- agent:exchange -->
1919### Re: one (HEAD)
1920a
1921### Re: two (HEAD)
1922b
1923### Re: three
1924c
1925<!-- /agent:exchange -->";
1926 let result = reposition_boundary_to_end(doc);
1927 assert_eq!(
1928 result.matches("(HEAD)").count(),
1929 1,
1930 "exactly one (HEAD) after reposition; got:\n{result}"
1931 );
1932 assert!(result.contains("### Re: three (HEAD)"));
1933 assert!(result.contains("### Re: one\n"));
1934 assert!(result.contains("### Re: two\n"));
1935 }
1936
1937 #[test]
1938 fn max_lines_inline_attr_trims_content() {
1939 let dir = setup_project();
1940 let doc_path = dir.path().join("test.md");
1941 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1942 std::fs::write(&doc_path, doc).unwrap();
1943
1944 let patches = vec![PatchBlock {
1945 name: "log".to_string(),
1946 content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1947 attrs: Default::default(),
1948 }];
1949 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1950 assert!(!result.contains("line1"));
1951 assert!(!result.contains("line2"));
1952 assert!(result.contains("line3"));
1953 assert!(result.contains("line4"));
1954 assert!(result.contains("line5"));
1955 }
1956
1957 #[test]
1958 fn max_lines_noop_when_under_limit() {
1959 let dir = setup_project();
1960 let doc_path = dir.path().join("test.md");
1961 let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1962 std::fs::write(&doc_path, doc).unwrap();
1963
1964 let patches = vec![PatchBlock {
1965 name: "log".to_string(),
1966 content: "line1\nline2\n".to_string(),
1967 attrs: Default::default(),
1968 }];
1969 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1970 assert!(result.contains("line1"));
1971 assert!(result.contains("line2"));
1972 }
1973
1974 #[test]
1975 fn max_lines_from_components_toml() {
1976 let dir = setup_project();
1977 let doc_path = dir.path().join("test.md");
1978 std::fs::write(
1979 dir.path().join(".agent-doc/config.toml"),
1980 "[components.log]\npatch = \"replace\"\nmax_lines = 2\n",
1981 )
1982 .unwrap();
1983 let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1984 std::fs::write(&doc_path, doc).unwrap();
1985
1986 let patches = vec![PatchBlock {
1987 name: "log".to_string(),
1988 content: "a\nb\nc\nd\n".to_string(),
1989 attrs: Default::default(),
1990 }];
1991 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1992 assert!(!result.contains("\na\n"));
1993 assert!(!result.contains("\nb\n"));
1994 assert!(result.contains("c"));
1995 assert!(result.contains("d"));
1996 }
1997
1998 #[test]
1999 fn max_lines_inline_beats_toml() {
2000 let dir = setup_project();
2001 let doc_path = dir.path().join("test.md");
2002 std::fs::write(
2003 dir.path().join(".agent-doc/config.toml"),
2004 "[components.log]\nmax_lines = 1\n",
2005 )
2006 .unwrap();
2007 let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
2008 std::fs::write(&doc_path, doc).unwrap();
2009
2010 let patches = vec![PatchBlock {
2011 name: "log".to_string(),
2012 content: "a\nb\nc\nd\n".to_string(),
2013 attrs: Default::default(),
2014 }];
2015 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2016 assert!(result.contains("b"));
2018 assert!(result.contains("c"));
2019 assert!(result.contains("d"));
2020 }
2021
2022 #[test]
2023 fn parse_patch_with_transfer_source_attr() {
2024 let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
2025 let (patches, unmatched) = parse_patches(response).unwrap();
2026 assert_eq!(patches.len(), 1);
2027 assert_eq!(patches[0].name, "exchange");
2028 assert_eq!(patches[0].content, "Transferred content.\n");
2029 assert_eq!(
2030 patches[0].attrs.get("transfer-source"),
2031 Some(&"\"tasks/eval-runner.md\"".to_string())
2032 );
2033 assert!(unmatched.is_empty());
2034 }
2035
2036 #[test]
2037 fn parse_patch_without_attrs() {
2038 let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
2039 let (patches, _) = parse_patches(response).unwrap();
2040 assert_eq!(patches.len(), 1);
2041 assert!(patches[0].attrs.is_empty());
2042 }
2043
2044 #[test]
2045 fn parse_patch_with_multiple_attrs() {
2046 let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
2047 let (patches, _) = parse_patches(response).unwrap();
2048 assert_eq!(patches.len(), 1);
2049 assert_eq!(patches[0].name, "output");
2050 assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
2051 assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
2052 }
2053
2054 #[test]
2055 fn apply_patches_dedup_exchange_adjacent_echo() {
2056 let dir = setup_project();
2060 let doc_path = dir.path().join("test.md");
2061 let doc = "\
2062<!-- agent:exchange patch=append -->
2063❯ How do I configure .mise.toml?
2064<!-- /agent:exchange -->
2065";
2066 std::fs::write(&doc_path, doc).unwrap();
2067
2068 let patches = vec![PatchBlock {
2070 name: "exchange".to_string(),
2071 content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
2072 attrs: Default::default(),
2073 }];
2074 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2075
2076 let count = result.matches("❯ How do I configure .mise.toml?").count();
2077 assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
2078 assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
2079 assert!(result.contains("Use `[env]` section."), "response body should be present");
2080 }
2081
2082 #[test]
2083 fn apply_patches_dedup_preserves_blank_lines() {
2084 let dir = setup_project();
2086 let doc_path = dir.path().join("test.md");
2087 let doc = "\
2088<!-- agent:exchange patch=append -->
2089Previous response.
2090<!-- /agent:exchange -->
2091";
2092 std::fs::write(&doc_path, doc).unwrap();
2093
2094 let patches = vec![PatchBlock {
2095 name: "exchange".to_string(),
2096 content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
2097 attrs: Default::default(),
2098 }];
2099 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2100 assert!(result.contains("Previous response."), "existing content preserved");
2101 assert!(result.contains("### Re: something"), "response heading present");
2102 assert!(result.contains('\n'), "blank lines preserved");
2104 }
2105
2106 #[test]
2107 fn apply_mode_append_strips_leading_overlap() {
2108 let existing = "❯ How do I configure .mise.toml?\n";
2111 let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
2112 let result = apply_mode("append", existing, new_content);
2113 let count = result.matches("❯ How do I configure .mise.toml?").count();
2114 assert_eq!(count, 1, "overlap line should appear exactly once");
2115 assert!(result.contains("### Re: configure"));
2116 }
2117
2118 #[test]
2119 fn strip_trailing_caret_removes_bare_prompt_line() {
2120 let content = "Answer text.\n❯\n";
2121 assert_eq!(strip_trailing_caret_lines(content), "Answer text.\n");
2122 }
2123
2124 #[test]
2125 fn strip_trailing_caret_removes_multiple_trailing_lines() {
2126 let content = "Answer.\n❯\n❯\n";
2127 assert_eq!(strip_trailing_caret_lines(content), "Answer.\n");
2128 }
2129
2130 #[test]
2131 fn strip_trailing_caret_preserves_mid_content_caret() {
2132 let content = "### Re: topic\n\n❯ user question echoed\n\nAnswer.\n";
2134 assert_eq!(strip_trailing_caret_lines(content), content);
2135 }
2136
2137 #[test]
2138 fn strip_trailing_caret_preserves_caret_with_text() {
2139 let content = "Answer.\n❯ follow-up\n";
2141 assert_eq!(strip_trailing_caret_lines(content), content);
2142 }
2143
2144 #[test]
2145 fn strip_trailing_caret_handles_no_trailing_newline() {
2146 let content = "Answer.\n❯";
2147 assert_eq!(strip_trailing_caret_lines(content), "Answer.");
2148 }
2149
2150 #[test]
2151 fn strip_trailing_caret_noop_when_no_caret() {
2152 let content = "Answer.\n";
2153 assert_eq!(strip_trailing_caret_lines(content), content);
2154 }
2155
2156 #[test]
2157 fn apply_patches_strips_trailing_caret_from_exchange() {
2158 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n❯ prior question\n<!-- /agent:exchange -->\n";
2159 let patches = vec![PatchBlock {
2160 name: "exchange".to_string(),
2161 content: "### Re: thing\n\nAnswer.\n❯\n".to_string(),
2162 attrs: Default::default(),
2163 }];
2164 let doc_path = std::path::PathBuf::from("/tmp/test.md");
2165 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2166 let components = component::parse(&result).unwrap();
2168 let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2169 let content = exchange.content(&result);
2170 let has_bare_caret_before_boundary = content
2172 .lines()
2173 .collect::<Vec<_>>()
2174 .windows(2)
2175 .any(|w| w[0].trim() == "❯" && w[1].starts_with("<!-- agent:boundary"));
2176 assert!(
2177 !has_bare_caret_before_boundary,
2178 "bare ❯ line must not appear before boundary marker. content:\n{}",
2179 content
2180 );
2181 }
2182
2183 #[test]
2184 fn apply_patches_preserves_caret_in_non_exchange() {
2185 let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:notes patch=replace -->\n<!-- /agent:notes -->\n";
2188 let patches = vec![PatchBlock {
2189 name: "notes".to_string(),
2190 content: "note body\n❯\n".to_string(),
2191 attrs: Default::default(),
2192 }];
2193 let doc_path = std::path::PathBuf::from("/tmp/test.md");
2194 let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2195 let components = component::parse(&result).unwrap();
2196 let notes = components.iter().find(|c| c.name == "notes").unwrap();
2197 assert!(notes.content(&result).contains("❯"), "non-exchange content retains ❯");
2198 }
2199
2200 #[test]
2201 fn apply_mode_append_no_overlap_unchanged() {
2202 let existing = "Previous content.\n";
2205 let new_content = "### Re: something\n\nAnswer.\n";
2206 let result = apply_mode("append", existing, new_content);
2207 assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
2208 }
2209}