use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use crate::component::{self, find_comment_end, Component};
#[derive(Debug, Clone)]
pub struct PatchBlock {
pub name: String,
pub content: String,
}
#[derive(Debug, Serialize)]
pub struct TemplateInfo {
pub template_mode: bool,
pub components: Vec<ComponentInfo>,
}
#[derive(Debug, Serialize)]
pub struct ComponentInfo {
pub name: String,
pub mode: String,
pub content: String,
pub line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_entries: Option<usize>,
}
#[cfg(test)]
pub fn is_template_mode(mode: Option<&str>) -> bool {
matches!(mode, Some("template"))
}
pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
let bytes = response.as_bytes();
let len = bytes.len();
let code_ranges = component::find_code_ranges(response);
let mut patches = Vec::new();
let mut unmatched = String::new();
let mut pos = 0;
let mut last_end = 0;
while pos + 4 <= len {
if &bytes[pos..pos + 4] != b"<!--" {
pos += 1;
continue;
}
if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
pos += 4;
continue;
}
let marker_start = pos;
let close = match find_comment_end(bytes, pos + 4) {
Some(c) => c,
None => {
pos += 4;
continue;
}
};
let inner = &response[marker_start + 4..close - 3];
let trimmed = inner.trim();
if let Some(name) = trimmed.strip_prefix("patch:") {
let name = name.trim();
if name.is_empty() || name.starts_with('/') {
pos = close;
continue;
}
let mut content_start = close;
if content_start < len && bytes[content_start] == b'\n' {
content_start += 1;
}
let before = &response[last_end..marker_start];
let trimmed_before = before.trim();
if !trimmed_before.is_empty() {
if !unmatched.is_empty() {
unmatched.push('\n');
}
unmatched.push_str(trimmed_before);
}
let close_marker = format!("<!-- /patch:{} -->", name);
if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
let content = &response[content_start..close_pos];
patches.push(PatchBlock {
name: name.to_string(),
content: content.to_string(),
});
let mut end = close_pos + close_marker.len();
if end < len && bytes[end] == b'\n' {
end += 1;
}
last_end = end;
pos = end;
continue;
}
}
pos = close;
}
if last_end < len {
let trailing = response[last_end..].trim();
if !trailing.is_empty() {
if !unmatched.is_empty() {
unmatched.push('\n');
}
unmatched.push_str(trailing);
}
}
Ok((patches, unmatched))
}
pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
}
pub fn apply_patches_with_overrides(
doc: &str,
patches: &[PatchBlock],
unmatched: &str,
file: &Path,
mode_overrides: &std::collections::HashMap<String, String>,
) -> Result<String> {
let summary = file.file_stem().and_then(|s| s.to_str());
let mut result = remove_all_boundaries(doc);
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
let id = crate::new_boundary_id_with_summary(summary);
let marker = crate::format_boundary_marker(&id);
let content = exchange.content(&result);
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
}
let components = component::parse(&result)
.context("failed to parse components")?;
let configs = load_component_configs(file);
let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
let mut overflow = String::new();
for patch in patches {
if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
ops.push((idx, patch));
} else {
let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
eprintln!(
"[template] patch target '{}' not found, routing to exchange/output. Available: {}",
patch.name,
available.join(", ")
);
if !overflow.is_empty() {
overflow.push('\n');
}
overflow.push_str(&patch.content);
}
}
ops.sort_by(|a, b| b.0.cmp(&a.0));
for (idx, patch) in &ops {
let comp = &components[*idx];
let mode = mode_overrides.get(&patch.name)
.map(|s| s.as_str())
.or_else(|| comp.patch_mode())
.or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
.unwrap_or_else(|| default_mode(&patch.name));
if mode == "append"
&& let Some(bid) = find_boundary_in_component(&result, comp)
{
result = comp.append_with_boundary(&result, &patch.content, &bid);
continue;
}
let new_content = apply_mode(mode, comp.content(&result), &patch.content);
result = comp.replace_content(&result, &new_content);
}
let mut all_unmatched = String::new();
if !overflow.is_empty() {
all_unmatched.push_str(&overflow);
}
if !unmatched.is_empty() {
if !all_unmatched.is_empty() {
all_unmatched.push('\n');
}
all_unmatched.push_str(unmatched);
}
if !all_unmatched.is_empty() {
let unmatched = &all_unmatched;
let components = component::parse(&result)
.context("failed to re-parse components after patching")?;
if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
if let Some(bid) = find_boundary_in_component(&result, output_comp) {
eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
result = output_comp.append_with_boundary(&result, unmatched, &bid);
} else {
let existing = output_comp.content(&result);
let new_content = if existing.trim().is_empty() {
format!("{}\n", unmatched)
} else {
format!("{}{}\n", existing, unmatched)
};
result = output_comp.replace_content(&result, &new_content);
}
} else {
if !result.ends_with('\n') {
result.push('\n');
}
result.push_str("\n<!-- agent:exchange -->\n");
result.push_str(unmatched);
result.push_str("\n<!-- /agent:exchange -->\n");
}
}
{
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
&& find_boundary_in_component(&result, exchange).is_none()
{
let id = uuid::Uuid::new_v4().to_string();
let marker = format!("<!-- agent:boundary:{} -->", id);
let content = exchange.content(&result);
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
}
}
Ok(result)
}
pub fn reposition_boundary_to_end(doc: &str) -> String {
reposition_boundary_to_end_with_summary(doc, None)
}
pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
let mut result = remove_all_boundaries(doc);
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
let id = crate::new_boundary_id_with_summary(summary);
let marker = crate::format_boundary_marker(&id);
let content = exchange.content(&result);
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
}
result
}
fn remove_all_boundaries(doc: &str) -> String {
let prefix = "<!-- agent:boundary:";
let suffix = " -->";
let code_ranges = component::find_code_ranges(doc);
let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
let mut result = String::with_capacity(doc.len());
let mut offset = 0;
for line in doc.lines() {
let trimmed = line.trim();
let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
if is_boundary && !in_code(offset) {
offset += line.len() + 1; continue;
}
result.push_str(line);
result.push('\n');
offset += line.len() + 1;
}
if !doc.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
let prefix = "<!-- agent:boundary:";
let suffix = " -->";
let content_region = &doc[comp.open_end..comp.close_start];
let code_ranges = component::find_code_ranges(doc);
let mut search_from = 0;
while let Some(start) = content_region[search_from..].find(prefix) {
let abs_start = comp.open_end + search_from + start;
if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
search_from += start + prefix.len();
continue;
}
let after_prefix = &content_region[search_from + start + prefix.len()..];
if let Some(end) = after_prefix.find(suffix) {
return Some(after_prefix[..end].trim().to_string());
}
break;
}
None
}
pub fn template_info(file: &Path) -> Result<TemplateInfo> {
let doc = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (fm, _body) = crate::frontmatter::parse(&doc)?;
let template_mode = fm.resolve_mode().is_template();
let components = component::parse(&doc)
.with_context(|| format!("failed to parse components in {}", file.display()))?;
let configs = load_component_configs(file);
let component_infos: Vec<ComponentInfo> = components
.iter()
.map(|comp| {
let content = comp.content(&doc).to_string();
let mode = comp.patch_mode().map(|s| s.to_string())
.or_else(|| configs.get(&comp.name).cloned())
.unwrap_or_else(|| default_mode(&comp.name).to_string());
let line = doc[..comp.open_start].matches('\n').count() + 1;
ComponentInfo {
name: comp.name.clone(),
mode,
content,
line,
max_entries: None, }
})
.collect();
Ok(TemplateInfo {
template_mode,
components: component_infos,
})
}
fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
let mut result = std::collections::HashMap::new();
let root = find_project_root(file);
if let Some(root) = root {
let config_path = root.join(".agent-doc/components.toml");
if config_path.exists()
&& let Ok(content) = std::fs::read_to_string(&config_path)
&& let Ok(table) = content.parse::<toml::Table>()
{
for (name, value) in &table {
if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
.or_else(|| value.get("mode").and_then(|v| v.as_str()))
{
result.insert(name.clone(), mode.to_string());
}
}
}
}
result
}
fn default_mode(name: &str) -> &'static str {
match name {
"exchange" | "findings" => "append",
_ => "replace",
}
}
fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
match mode {
"append" => format!("{}{}", existing, new_content),
"prepend" => format!("{}{}", new_content, existing),
_ => new_content.to_string(), }
}
fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
let canonical = file.canonicalize().ok()?;
let mut dir = canonical.parent()?;
loop {
if dir.join(".agent-doc").is_dir() {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
let mut search_start = from;
loop {
let rel = haystack[search_start..].find(needle)?;
let abs = search_start + rel;
if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
search_start = abs + needle.len();
continue;
}
return Some(abs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_project() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
dir
}
#[test]
fn parse_single_patch() {
let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[0].content, "Build passing.\n");
assert!(unmatched.is_empty());
}
#[test]
fn parse_multiple_patches() {
let response = "\
<!-- patch:status -->
All green.
<!-- /patch:status -->
<!-- patch:log -->
- New entry
<!-- /patch:log -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[0].content, "All green.\n");
assert_eq!(patches[1].name, "log");
assert_eq!(patches[1].content, "- New entry\n");
assert!(unmatched.is_empty());
}
#[test]
fn parse_with_unmatched_content() {
let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "status");
assert!(unmatched.contains("Some free text."));
assert!(unmatched.contains("Trailing text."));
}
#[test]
fn parse_empty_response() {
let (patches, unmatched) = parse_patches("").unwrap();
assert!(patches.is_empty());
assert!(unmatched.is_empty());
}
#[test]
fn parse_no_patches() {
let response = "Just a plain response with no patch blocks.";
let (patches, unmatched) = parse_patches(response).unwrap();
assert!(patches.is_empty());
assert_eq!(unmatched, "Just a plain response with no patch blocks.");
}
#[test]
fn apply_patches_replace() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("\nold\n"));
assert!(result.contains("<!-- agent:status -->"));
}
#[test]
fn apply_patches_unmatched_creates_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
assert!(result.contains("<!-- agent:exchange -->"));
assert!(result.contains("Extra info here"));
assert!(result.contains("<!-- /agent:exchange -->"));
}
#[test]
fn apply_patches_unmatched_appends_to_existing_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
assert!(result.contains("previous"));
assert!(result.contains("new stuff"));
assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
}
#[test]
fn apply_patches_missing_component_routes_to_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "nonexistent".to_string(),
content: "overflow data\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
assert!(result.contains("previous"), "existing exchange content should be preserved");
}
#[test]
fn apply_patches_missing_component_creates_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "nonexistent".to_string(),
content: "overflow data\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
assert!(result.contains("overflow data"), "overflow content should be in exchange");
}
#[test]
fn is_template_mode_detection() {
assert!(is_template_mode(Some("template")));
assert!(!is_template_mode(Some("append")));
assert!(!is_template_mode(None));
}
#[test]
fn template_info_works() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let info = template_info(&doc_path).unwrap();
assert!(info.template_mode);
assert_eq!(info.components.len(), 1);
assert_eq!(info.components[0].name, "status");
assert_eq!(info.components[0].content, "content\n");
}
#[test]
fn template_info_legacy_mode_works() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let info = template_info(&doc_path).unwrap();
assert!(info.template_mode);
}
#[test]
fn template_info_append_mode() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
std::fs::write(&doc_path, doc).unwrap();
let info = template_info(&doc_path).unwrap();
assert!(!info.template_mode);
assert!(info.components.is_empty());
}
#[test]
fn parse_patches_ignores_markers_in_fenced_code_block() {
let response = "\
<!-- patch:exchange -->
Here is how you use component markers:
```markdown
<!-- agent:exchange -->
example content
<!-- /agent:exchange -->
```
<!-- /patch:exchange -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "exchange");
assert!(patches[0].content.contains("```markdown"));
assert!(patches[0].content.contains("<!-- agent:exchange -->"));
assert!(unmatched.is_empty());
}
#[test]
fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
let response = "\
<!-- patch:exchange -->
Real content here.
```markdown
<!-- patch:fake -->
This is just an example.
<!-- /patch:fake -->
```
<!-- /patch:exchange -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1, "should only find the outer real patch");
assert_eq!(patches[0].name, "exchange");
assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
assert!(unmatched.is_empty());
}
#[test]
fn parse_patches_ignores_markers_in_tilde_fence() {
let response = "\
<!-- patch:status -->
OK
<!-- /patch:status -->
~~~
<!-- patch:fake -->
example
<!-- /patch:fake -->
~~~
";
let (patches, _unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "status");
}
#[test]
fn parse_patches_ignores_closing_marker_in_code_block() {
let response = "\
<!-- patch:exchange -->
Example:
```
<!-- /patch:exchange -->
```
Real content continues.
<!-- /patch:exchange -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "exchange");
assert!(patches[0].content.contains("Real content continues."));
}
#[test]
fn parse_patches_normal_markers_still_work() {
let response = "\
<!-- patch:status -->
All systems go.
<!-- /patch:status -->
<!-- patch:log -->
- Entry 1
<!-- /patch:log -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[0].content, "All systems go.\n");
assert_eq!(patches[1].name, "log");
assert_eq!(patches[1].content, "- Entry 1\n");
assert!(unmatched.is_empty());
}
#[test]
fn inline_attr_mode_overrides_config() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/components.toml"),
"[status]\nmode = \"append\"\n",
).unwrap();
let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn inline_attr_mode_overrides_default() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn no_inline_attr_falls_back_to_config() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/components.toml"),
"[status]\nmode = \"append\"\n",
).unwrap();
let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("old\n"));
assert!(result.contains("new\n"));
}
#[test]
fn no_inline_attr_no_config_falls_back_to_default() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("old\n"));
assert!(result.contains("new\n"));
}
#[test]
fn inline_patch_attr_overrides_config() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/components.toml"),
"[status]\nmode = \"append\"\n",
).unwrap();
let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn inline_patch_attr_overrides_mode_attr() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn toml_patch_key_works() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/components.toml"),
"[status]\npatch = \"append\"\n",
).unwrap();
let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("old\n"));
assert!(result.contains("new\n"));
}
#[test]
fn stream_override_beats_inline_attr() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
}];
let mut overrides = std::collections::HashMap::new();
overrides.insert("exchange".to_string(), "replace".to_string());
let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn apply_patches_ignores_component_tags_in_code_blocks() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
# Scaffold Guide
Here is an example of a component:
```markdown
<!-- agent:status -->
example scaffold content
<!-- /agent:status -->
```
<!-- agent:status -->
real status content
<!-- /agent:status -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "patched status\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("patched status\n"), "real component should receive the patch");
assert!(result.contains("example scaffold content"), "code block content should be preserved");
assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
}
#[test]
fn unmatched_content_uses_boundary_marker() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = concat!(
"---\nagent_doc_format: template\n---\n",
"<!-- agent:exchange patch=append -->\n",
"User prompt here.\n",
"<!-- agent:boundary:test-uuid-123 -->\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&file, doc).unwrap();
let patches = vec![];
let unmatched = "### Re: Response\n\nResponse content here.\n";
let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
let prompt_pos = result.find("User prompt here.").unwrap();
let response_pos = result.find("### Re: Response").unwrap();
assert!(
response_pos > prompt_pos,
"response should appear after the user prompt (boundary insertion)"
);
assert!(
!result.contains("test-uuid-123"),
"boundary marker should be consumed after insertion"
);
}
#[test]
fn explicit_patch_uses_boundary_marker() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = concat!(
"---\nagent_doc_format: template\n---\n",
"<!-- agent:exchange patch=append -->\n",
"User prompt here.\n",
"<!-- agent:boundary:patch-uuid-456 -->\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&file, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: Response\n\nResponse content.\n".to_string(),
}];
let result = apply_patches(doc, &patches, "", &file).unwrap();
let prompt_pos = result.find("User prompt here.").unwrap();
let response_pos = result.find("### Re: Response").unwrap();
assert!(
response_pos > prompt_pos,
"response should appear after user prompt"
);
assert!(
!result.contains("patch-uuid-456"),
"boundary marker should be consumed by explicit patch"
);
}
#[test]
fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
std::fs::write(&file, doc).unwrap();
let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
assert!(
result.contains("<!-- agent:boundary:"),
"boundary must be re-inserted even when original doc had no boundary: {result}"
);
}
#[test]
fn boundary_survives_multiple_cycles() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
std::fs::write(&file, doc).unwrap();
let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
let (patches1, unmatched1) = parse_patches(response1).unwrap();
let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
let (patches2, unmatched2) = parse_patches(response2).unwrap();
let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
}
#[test]
fn remove_all_boundaries_skips_code_blocks() {
let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
let result = remove_all_boundaries(doc);
assert!(
result.contains("<!-- agent:boundary:fake-id -->"),
"boundary inside code block must be preserved: {result}"
);
assert!(
!result.contains("<!-- agent:boundary:real-id -->"),
"boundary outside code block must be removed: {result}"
);
}
#[test]
fn reposition_boundary_moves_to_end() {
let doc = "\
<!-- agent:exchange -->
Previous response.
<!-- agent:boundary:old-id -->
User prompt here.
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end(doc);
assert!(!result.contains("old-id"), "old boundary should be removed");
assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
let prompt_pos = result.find("User prompt here.").unwrap();
let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
assert!(boundary_pos < close_pos, "boundary should be before close tag");
}
#[test]
fn reposition_boundary_no_exchange_unchanged() {
let doc = "\
<!-- agent:output -->
Some content.
<!-- /agent:output -->";
let result = reposition_boundary_to_end(doc);
assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
}
}