use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use crate::component::{self, find_comment_end, Component};
use crate::project_config;
#[derive(Debug, Clone)]
pub struct PatchBlock {
pub name: String,
pub content: String,
#[allow(dead_code)]
pub attrs: std::collections::HashMap<String, String>,
}
impl PatchBlock {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
PatchBlock {
name: name.into(),
content: content.into(),
attrs: std::collections::HashMap::new(),
}
}
}
#[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(rest) = trimmed.strip_prefix("patch:") {
let rest = rest.trim();
if rest.is_empty() || rest.starts_with('/') {
pos = close;
continue;
}
let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
let name = &rest[..space_idx];
let attr_text = rest[space_idx..].trim();
(name, component::parse_attrs(attr_text))
} else {
(rest, std::collections::HashMap::new())
};
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(),
attrs,
});
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");
}
}
result = dedup_exchange_adjacent_lines(&result);
{
let max_lines_configs = load_max_lines_configs(file);
'stability: for _ in 0..3 {
let Ok(components) = component::parse(&result) else { break };
for comp in &components {
let max_lines = comp
.attrs
.get("max_lines")
.and_then(|s| s.parse::<usize>().ok())
.or_else(|| max_lines_configs.get(&comp.name).copied())
.unwrap_or(0);
if max_lines > 0 {
let content = comp.content(&result);
let trimmed = limit_lines(content, max_lines);
if trimmed.len() != content.len() {
let trimmed = format!("{}\n", trimmed.trim_end());
result = comp.replace_content(&result, &trimmed);
continue 'stability;
}
}
}
break; }
}
{
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 proj_cfg = load_project_from_doc(file);
proj_cfg
.components
.iter()
.map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.patch.clone()))
.collect()
}
fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
let proj_cfg = load_project_from_doc(file);
proj_cfg
.components
.iter()
.filter(|(_, cfg)| cfg.max_lines > 0)
.map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.max_lines))
.collect()
}
fn load_project_from_doc(file: &Path) -> project_config::ProjectConfig {
let start = file.parent().unwrap_or(file);
let mut current = start;
loop {
let candidate = current.join(".agent-doc").join("config.toml");
if candidate.exists() {
return project_config::load_project_from(&candidate);
}
match current.parent() {
Some(p) if p != current => current = p,
_ => break,
}
}
project_config::load_project()
}
fn default_mode(name: &str) -> &'static str {
match name {
"exchange" | "findings" => "append",
_ => "replace",
}
}
fn limit_lines(content: &str, max_lines: usize) -> String {
let lines: Vec<&str> = content.lines().collect();
if lines.len() <= max_lines {
return content.to_string();
}
lines[lines.len() - max_lines..].join("\n")
}
fn dedup_exchange_adjacent_lines(doc: &str) -> String {
let Ok(components) = component::parse(doc) else {
return doc.to_string();
};
let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
return doc.to_string();
};
let content = exchange.content(doc);
let mut deduped = String::with_capacity(content.len());
let mut prev_nonempty: Option<&str> = None;
for line in content.lines() {
if !line.trim().is_empty() && prev_nonempty == Some(line) {
continue;
}
deduped.push_str(line);
deduped.push('\n');
if !line.trim().is_empty() {
prev_nonempty = Some(line);
}
}
if !content.ends_with('\n') && deduped.ends_with('\n') {
deduped.pop();
}
if deduped == content {
return doc.to_string();
}
exchange.replace_content(doc, &deduped)
}
fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
match mode {
"append" => {
let stripped = strip_leading_overlap(existing, new_content);
format!("{}{}", existing, stripped)
}
"prepend" => format!("{}{}", new_content, existing),
_ => new_content.to_string(), }
}
fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
let Some(last) = last_nonempty else {
return new_content;
};
let test = format!("{}\n", last);
if new_content.starts_with(test.as_str()) {
&new_content[test.len()..]
} else {
new_content
}
}
#[allow(dead_code)]
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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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/config.toml"),
"[components.status]\npatch = \"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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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/config.toml"),
"[components.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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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/config.toml"),
"[components.status]\npatch = \"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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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/config.toml"),
"[components.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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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(),
attrs: Default::default(),
}];
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");
}
#[test]
fn max_lines_inline_attr_trims_content() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(!result.contains("line1"));
assert!(!result.contains("line2"));
assert!(result.contains("line3"));
assert!(result.contains("line4"));
assert!(result.contains("line5"));
}
#[test]
fn max_lines_noop_when_under_limit() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "line1\nline2\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("line1"));
assert!(result.contains("line2"));
}
#[test]
fn max_lines_from_components_toml() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/config.toml"),
"[components.log]\npatch = \"replace\"\nmax_lines = 2\n",
)
.unwrap();
let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "a\nb\nc\nd\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(!result.contains("\na\n"));
assert!(!result.contains("\nb\n"));
assert!(result.contains("c"));
assert!(result.contains("d"));
}
#[test]
fn max_lines_inline_beats_toml() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/config.toml"),
"[components.log]\nmax_lines = 1\n",
)
.unwrap();
let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "a\nb\nc\nd\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("b"));
assert!(result.contains("c"));
assert!(result.contains("d"));
}
#[test]
fn parse_patch_with_transfer_source_attr() {
let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "exchange");
assert_eq!(patches[0].content, "Transferred content.\n");
assert_eq!(
patches[0].attrs.get("transfer-source"),
Some(&"\"tasks/eval-runner.md\"".to_string())
);
assert!(unmatched.is_empty());
}
#[test]
fn parse_patch_without_attrs() {
let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
let (patches, _) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert!(patches[0].attrs.is_empty());
}
#[test]
fn parse_patch_with_multiple_attrs() {
let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
let (patches, _) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "output");
assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
}
#[test]
fn apply_patches_dedup_exchange_adjacent_echo() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
❯ How do I configure .mise.toml?
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
let count = result.matches("❯ How do I configure .mise.toml?").count();
assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
assert!(result.contains("Use `[env]` section."), "response body should be present");
}
#[test]
fn apply_patches_dedup_preserves_blank_lines() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
Previous response.
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("Previous response."), "existing content preserved");
assert!(result.contains("### Re: something"), "response heading present");
assert!(result.contains('\n'), "blank lines preserved");
}
#[test]
fn apply_mode_append_strips_leading_overlap() {
let existing = "❯ How do I configure .mise.toml?\n";
let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
let result = apply_mode("append", existing, new_content);
let count = result.matches("❯ How do I configure .mise.toml?").count();
assert_eq!(count, 1, "overlap line should appear exactly once");
assert!(result.contains("### Re: configure"));
}
#[test]
fn apply_mode_append_no_overlap_unchanged() {
let existing = "Previous content.\n";
let new_content = "### Re: something\n\nAnswer.\n";
let result = apply_mode("append", existing, new_content);
assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
}
}