use anyhow::Result;
use similar::{ChangeTag, TextDiff};
use std::path::Path;
use crate::{component, snapshot};
pub fn strip_comments(content: &str) -> String {
let code_ranges = component::find_code_ranges(content);
let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
let mut result = String::with_capacity(content.len());
let bytes = content.as_bytes();
let len = bytes.len();
let mut pos = 0;
while pos < len {
if bytes[pos] == b'['
&& !in_code(pos)
&& is_line_start(bytes, pos)
&& let Some(end) = match_link_ref_comment(bytes, pos)
{
pos = end;
continue;
}
if pos + 4 <= len
&& &bytes[pos..pos + 4] == b"<!--"
&& !in_code(pos)
&& let Some((end, inner)) = match_html_comment(content, pos)
{
if component::is_agent_marker(inner) {
result.push_str(&content[pos..end]);
pos = end;
} else {
let mut skip_to = end;
if is_line_start(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
skip_to += 1;
}
pos = skip_to;
}
continue;
}
result.push(content[pos..].chars().next().unwrap());
pos += content[pos..].chars().next().unwrap().len_utf8();
}
result
}
fn is_line_start(bytes: &[u8], pos: usize) -> bool {
pos == 0 || bytes[pos - 1] == b'\n'
}
fn match_link_ref_comment(bytes: &[u8], pos: usize) -> Option<usize> {
let prefix = b"[//]: # (";
let len = bytes.len();
if pos + prefix.len() > len {
return None;
}
if &bytes[pos..pos + prefix.len()] != prefix {
return None;
}
let mut i = pos + prefix.len();
while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
i += 1;
}
if i < len && bytes[i] == b')' {
i += 1; if i < len && bytes[i] == b'\n' {
i += 1; }
Some(i)
} else {
None
}
}
fn match_html_comment(content: &str, pos: usize) -> Option<(usize, &str)> {
let bytes = content.as_bytes();
let len = bytes.len();
let mut i = pos + 4; while i + 3 <= len {
if &bytes[i..i + 3] == b"-->" {
let inner = &content[pos + 4..i];
return Some((i + 3, inner));
}
i += 1;
}
None
}
pub fn compute(doc: &Path) -> Result<Option<String>> {
let t_total = std::time::Instant::now();
let previous = snapshot::resolve(doc)?.unwrap_or_default();
let snap_path = snapshot::path_for(doc)?;
let current = wait_for_stable_content(doc, &previous)?;
eprintln!(
"[diff] doc={} snapshot={} doc_len={} snap_len={}",
doc.display(),
snap_path.display(),
current.len(),
previous.len(),
);
let t_strip = std::time::Instant::now();
let current_stripped = strip_comments(¤t);
let previous_stripped = strip_comments(&previous);
let elapsed_strip = t_strip.elapsed().as_millis();
if elapsed_strip > 0 {
eprintln!("[perf] diff.strip_comments: {}ms", elapsed_strip);
}
eprintln!(
"[diff] stripped: doc_len={} snap_len={}",
current_stripped.len(),
previous_stripped.len(),
);
let diff = TextDiff::from_lines(&previous_stripped, ¤t_stripped);
let has_changes = diff
.iter_all_changes()
.any(|c| c.tag() != ChangeTag::Equal);
if !has_changes {
eprintln!("[diff] no changes detected between snapshot and document (after comment stripping)");
let elapsed_total = t_total.elapsed().as_millis();
if elapsed_total > 0 {
eprintln!("[perf] diff.compute total: {}ms", elapsed_total);
}
return Ok(None);
}
if is_stale_snapshot(&previous, ¤t) {
eprintln!("[snapshot recovery] Snapshot synced — previous cycle completed but snapshot was stale");
snapshot::save(doc, ¤t)?;
let elapsed_total = t_total.elapsed().as_millis();
if elapsed_total > 0 {
eprintln!("[perf] diff.compute total: {}ms", elapsed_total);
}
return Ok(None);
}
eprintln!("[diff] changes detected, computing unified diff");
let output = diff
.unified_diff()
.context_radius(5)
.header("snapshot", "document")
.to_string();
let elapsed_total = t_total.elapsed().as_millis();
if elapsed_total > 0 {
eprintln!("[perf] diff.compute total: {}ms", elapsed_total);
}
Ok(Some(output))
}
pub fn wait_for_stable_content(doc: &Path, previous: &str) -> Result<String> {
const RECHECK_DELAY_MS: u64 = 500;
const MAX_RECHECKS: u32 = 12; const STABLE_CHECKS_REQUIRED: u32 = 3;
let mut current = std::fs::read_to_string(doc)?;
for attempt in 0..MAX_RECHECKS {
let last_added = extract_last_added_line(&strip_comments(previous), &strip_comments(¤t));
if let Some(line) = &last_added
&& looks_truncated(line)
{
eprintln!(
"[diff] Last line may be truncated (recheck {}/{}): {:?}",
attempt + 1,
MAX_RECHECKS,
truncate_for_log(line, 60)
);
let mut stable_count = 0u32;
for _check in 0..STABLE_CHECKS_REQUIRED {
std::thread::sleep(std::time::Duration::from_millis(RECHECK_DELAY_MS));
let refreshed = std::fs::read_to_string(doc)?;
if refreshed == current {
stable_count += 1;
} else {
current = refreshed;
stable_count = 0;
break;
}
}
if stable_count >= STABLE_CHECKS_REQUIRED {
eprintln!("[diff] Content stable after {} consecutive checks", STABLE_CHECKS_REQUIRED);
break;
}
continue;
}
break;
}
Ok(current)
}
fn extract_last_added_line(previous_stripped: &str, current_stripped: &str) -> Option<String> {
let diff = TextDiff::from_lines(previous_stripped, current_stripped);
let mut last_insert: Option<String> = None;
for change in diff.iter_all_changes() {
if change.tag() == ChangeTag::Insert {
let val = change.value().trim();
if !val.is_empty() {
last_insert = Some(val.to_string());
}
}
}
last_insert
}
fn looks_truncated(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with('/')
|| trimmed.starts_with('#')
|| trimmed.starts_with("```")
|| trimmed.starts_with("<!--")
{
return false;
}
if trimmed.len() == 1 && trimmed.chars().next().is_some_and(|c| c.is_alphanumeric()) {
return false;
}
if !trimmed.contains(' ') && trimmed.len() >= 2 {
if trimmed.ends_with('.') && trimmed.chars().filter(|&c| c == '.').count() >= 1 {
let before_dot = &trimmed[..trimmed.len() - 1];
if !before_dot.is_empty() && before_dot.chars().all(|c| c.is_alphanumeric() || c == '-') {
return true;
}
}
return false;
}
let last_char = trimmed.chars().last().unwrap();
if last_char == '.' {
let before_dot = &trimmed[..trimmed.len() - 1];
let last_word = before_dot.rsplit_once(' ').map(|(_, w)| w).unwrap_or(before_dot);
if last_word.contains('.') || last_word.ends_with("http") || last_word.ends_with("https") {
return true;
}
return false;
}
let terminal = matches!(last_char, '!' | '?' | ':' | ';' | ')' | ']' | '"' | '\'' | '`' | '*' | '-' | '>' | '|');
!terminal
}
fn truncate_for_log(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
pub fn is_stale_snapshot(snapshot_content: &str, document_content: &str) -> bool {
let snap_stripped = strip_comments(snapshot_content);
let doc_stripped = strip_comments(document_content);
if doc_stripped.len() <= snap_stripped.len() {
return false;
}
let snap_trimmed = snap_stripped.trim_end();
let doc_trimmed = doc_stripped.trim_end();
if !doc_trimmed.starts_with(snap_trimmed) {
return false;
}
let extra = &doc_stripped[snap_trimmed.len()..];
let extra_trimmed = extra.trim();
if extra_trimmed.is_empty() {
return false;
}
if !extra_trimmed.contains("## Assistant") {
return false;
}
let parts: Vec<&str> = extra_trimmed.split("## User").collect();
if let Some(last_user_block) = parts.last() {
let user_content = last_user_block.trim();
user_content.is_empty()
} else {
false
}
}
pub fn run(file: &Path, wait: bool) -> Result<()> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
if wait {
let previous = snapshot::resolve(file)?.unwrap_or_default();
let _stable = wait_for_stable_content(file, &previous)?;
eprintln!("[diff --wait] content is stable");
}
match compute(file)? {
Some(diff) => print!("{}", diff),
None => eprintln!("No changes since last run."),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_format_additions() {
use similar::{ChangeTag, TextDiff};
let previous = "line1\n";
let current = "line1\nline2\n";
let diff = TextDiff::from_lines(previous, current);
let has_insert = diff.iter_all_changes().any(|c| c.tag() == ChangeTag::Insert);
assert!(has_insert);
}
#[test]
fn diff_format_deletions() {
use similar::{ChangeTag, TextDiff};
let previous = "line1\nline2\n";
let current = "line1\n";
let diff = TextDiff::from_lines(previous, current);
let has_delete = diff.iter_all_changes().any(|c| c.tag() == ChangeTag::Delete);
assert!(has_delete);
}
#[test]
fn diff_format_unchanged() {
use similar::{ChangeTag, TextDiff};
let content = "line1\nline2\n";
let diff = TextDiff::from_lines(content, content);
let all_equal = diff.iter_all_changes().all(|c| c.tag() == ChangeTag::Equal);
assert!(all_equal);
}
#[test]
fn diff_format_mixed() {
use similar::{ChangeTag, TextDiff};
let previous = "line1\nline2\nline3\n";
let current = "line1\nchanged\nline3\n";
let diff = TextDiff::from_lines(previous, current);
let mut output = String::new();
for change in diff.iter_all_changes() {
let prefix = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
output.push_str(prefix);
output.push_str(change.value());
}
assert!(output.contains(" line1\n"));
assert!(output.contains("-line2\n"));
assert!(output.contains("+changed\n"));
assert!(output.contains(" line3\n"));
}
#[test]
fn run_file_not_found() {
let err = run(Path::new("/nonexistent/file.md"), false).unwrap_err();
assert!(err.to_string().contains("file not found"));
}
#[test]
fn strip_html_comment() {
let input = "before\n<!-- a comment -->\nafter\n";
assert_eq!(strip_comments(input), "before\nafter\n");
}
#[test]
fn strip_multiline_html_comment() {
let input = "before\n<!--\nmulti\nline\n-->\nafter\n";
assert_eq!(strip_comments(input), "before\nafter\n");
}
#[test]
fn strip_link_ref_comment() {
let input = "before\n[//]: # (a comment)\nafter\n";
assert_eq!(strip_comments(input), "before\nafter\n");
}
#[test]
fn preserve_agent_markers() {
let input = "<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
assert_eq!(strip_comments(input), input);
}
#[test]
fn strip_regular_keep_agent_marker() {
let input = "<!-- regular comment -->\n<!-- agent:s -->\ndata\n<!-- /agent:s -->\n";
assert_eq!(
strip_comments(input),
"<!-- agent:s -->\ndata\n<!-- /agent:s -->\n"
);
}
#[test]
fn strip_inline_comment() {
let input = "text <!-- note --> more\n";
let result = strip_comments(input);
assert_eq!(result, "text more\n");
}
#[test]
fn no_comments_unchanged() {
let input = "# Title\n\nJust text.\n";
assert_eq!(strip_comments(input), input);
}
#[test]
fn empty_document() {
assert_eq!(strip_comments(""), "");
}
#[test]
fn stale_snapshot_detects_completed_exchange() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
let document = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nWhat's up\n\n## Assistant\n\nNot much\n\n## User\n\n";
assert!(is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_false_when_user_has_new_content() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
let document = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nNew question here\n";
assert!(!is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_false_when_identical() {
let content = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
assert!(!is_stale_snapshot(content, content));
}
#[test]
fn stale_snapshot_false_when_no_assistant_block() {
let snapshot = "## User\n\nHello\n\n";
let document = "## User\n\nHello\n\nSome random text\n\n## User\n\n";
assert!(!is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_multiple_exchanges_stale() {
let snapshot = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\n";
let document = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\nQ2\n\n## Assistant\n\nA2\n\n## User\n\nQ3\n\n## Assistant\n\nA3\n\n## User\n\n";
assert!(is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_with_inline_annotation_not_stale() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
let document = "## User\n\nHello\n\n## Assistant\n\nHi there\n\nPlease elaborate\n\n## User\n\n";
assert!(!is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_ignores_comments_in_detection() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
let document = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n<!-- scratch -->\n\n## Assistant\n\nResponse\n\n## User\n\n";
assert!(is_stale_snapshot(snapshot, document));
}
#[test]
fn strip_preserves_comment_syntax_in_inline_backticks() {
let input = "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n";
let result = strip_comments(input);
assert_eq!(
result,
"Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n"
);
}
#[test]
fn strip_preserves_comment_syntax_in_fenced_code_block() {
let input = "before\n```\n<!-- not a comment -->\n```\nafter\n";
let result = strip_comments(input);
assert_eq!(result, input);
}
#[test]
fn strip_backtick_comment_before_agent_marker() {
let input = "\
Text mentions `<!--` as a trigger.\n\
More text here.\n\
New user content.\n\
<!-- /agent:exchange -->\n";
let result = strip_comments(input);
assert_eq!(result, input);
}
#[test]
fn strip_multiple_backtick_comments_in_exchange() {
let snapshot = "\
<!-- agent:exchange -->\n\
Discussion about `<!--` triggers.\n\
- `<!-- agent:NAME -->` paired markers\n\
<!-- /agent:exchange -->\n";
let current = "\
<!-- agent:exchange -->\n\
Discussion about `<!--` triggers.\n\
- `<!-- agent:NAME -->` paired markers\n\
\n\
Please fix the bug.\n\
<!-- /agent:exchange -->\n";
let snap_stripped = strip_comments(snapshot);
let curr_stripped = strip_comments(current);
assert_ne!(
snap_stripped, curr_stripped,
"inline edits after backtick-comment text must be detected"
);
}
#[test]
fn diff_detects_user_edits_after_stream_write() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
let doc = dir.path().join("test.md");
let content_after_write = "---\nagent_doc_mode: template\n---\n\n<!-- agent:exchange -->\nUser prompt\n\nAgent response\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content_after_write).unwrap();
snapshot::save(&doc, content_after_write).unwrap();
let content_after_edit = "---\nagent_doc_mode: template\n---\n\n<!-- agent:exchange -->\nUser prompt\n\nAgent response\n\nNew user edit here\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content_after_edit).unwrap();
let diff = compute(&doc).unwrap();
assert!(diff.is_some(), "diff should detect user edit after stream write");
let diff_text = diff.unwrap();
assert!(diff_text.contains("New user edit here"), "diff should contain user's new text: {}", diff_text);
}
#[test]
fn diff_no_change_when_document_matches_snapshot() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: template\n---\n\n<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content).unwrap();
snapshot::save(&doc, content).unwrap();
let diff = compute(&doc).unwrap();
assert!(diff.is_none(), "no diff when document matches snapshot");
}
#[test]
fn diff_detects_change_after_cumulative_stream_flushes() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
let doc = dir.path().join("test.md");
let snapshot_content = "---\nagent_doc_mode: template\n---\n\n<!-- agent:exchange -->\nFull agent response here\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, snapshot_content).unwrap();
snapshot::save(&doc, snapshot_content).unwrap();
let edited = "---\nagent_doc_mode: template\n---\n\n<!-- agent:exchange -->\nFull agent response here\n\nRelease agent-doc\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, edited).unwrap();
let diff = compute(&doc).unwrap();
assert!(diff.is_some(), "diff should detect user's edit");
assert!(diff.unwrap().contains("Release agent-doc"));
}
#[test]
fn truncated_mid_sentence() {
assert!(looks_truncated("Also, when I called agent-doc run on this file...and ther"));
}
#[test]
fn not_truncated_complete_sentence() {
assert!(!looks_truncated("This is a complete sentence."));
}
#[test]
fn not_truncated_question() {
assert!(!looks_truncated("What should we do?"));
}
#[test]
fn not_truncated_command() {
assert!(!looks_truncated("/agent-doc compact"));
}
#[test]
fn not_truncated_single_word_command() {
assert!(!looks_truncated("release"));
}
#[test]
fn not_truncated_short_words() {
assert!(!looks_truncated("go"));
assert!(!looks_truncated("ok"));
assert!(!looks_truncated("no"));
assert!(!looks_truncated("yes"));
}
#[test]
fn not_truncated_single_alphanumeric() {
assert!(!looks_truncated("A"));
assert!(!looks_truncated("B"));
assert!(!looks_truncated("1"));
assert!(!looks_truncated("2"));
assert!(!looks_truncated("y"));
assert!(!looks_truncated("n"));
}
#[test]
fn not_truncated_heading() {
assert!(!looks_truncated("### Re: Fix the bug"));
}
#[test]
fn not_truncated_empty() {
assert!(!looks_truncated(""));
}
#[test]
fn not_truncated_ends_with_colon() {
assert!(!looks_truncated("Here is the issue:"));
}
#[test]
fn not_truncated_ends_with_backtick() {
assert!(!looks_truncated("Check `crdt.rs`"));
}
#[test]
fn truncated_ends_mid_word() {
assert!(looks_truncated("Please make Claim for Tmux Pan"));
}
#[test]
fn not_truncated_ends_with_period() {
assert!(!looks_truncated("Fixed the bug."));
}
#[test]
fn extract_last_added_finds_insert() {
let prev = "line1\n";
let curr = "line1\nnew content here\n";
let last = extract_last_added_line(prev, curr);
assert_eq!(last, Some("new content here".to_string()));
}
#[test]
fn extract_last_added_none_when_no_changes() {
let content = "line1\nline2\n";
let last = extract_last_added_line(content, content);
assert_eq!(last, None);
}
#[test]
fn run_with_wait_stable_content() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
let doc = dir.path().join("test.md");
let snapshot_content = "line1\n";
std::fs::write(&doc, "line1\nline2\n").unwrap();
snapshot::save(&doc, snapshot_content).unwrap();
let result = run(&doc, true);
assert!(result.is_ok());
}
#[test]
fn run_with_wait_no_changes() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
let doc = dir.path().join("test.md");
let content = "line1\nline2\n";
std::fs::write(&doc, content).unwrap();
snapshot::save(&doc, content).unwrap();
let result = run(&doc, true);
assert!(result.is_ok());
}
#[test]
fn wait_for_stable_content_returns_immediately_when_complete() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
let content = "Complete sentence.\n";
std::fs::write(&doc, content).unwrap();
let previous = "";
let start = std::time::Instant::now();
let result = wait_for_stable_content(&doc, previous).unwrap();
let elapsed = start.elapsed();
assert_eq!(result, content);
assert!(elapsed.as_millis() < 500, "should not delay for complete content");
}
}