use anyhow::Result;
use serde::Serialize;
use similar::{ChangeTag, TextDiff};
use std::path::Path;
use crate::{component, snapshot};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffType {
Approval,
SimpleQuestion,
BoundaryArtifact,
Annotation,
StructuralChange,
MultiTopic,
ContentAddition,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffClassification {
pub diff_type: DiffType,
pub diff_type_reason: String,
}
pub fn strip_comments(content: &str) -> String {
component::strip_comments(content)
}
pub fn annotate_diff(diff_text: &str) -> Option<String> {
let mut result = Vec::new();
let mut pending_removes: Vec<String> = Vec::new();
for line in diff_text.lines() {
if line.starts_with("--- ") || line.starts_with("+++ ") {
continue;
}
if line.starts_with("@@ ") {
flush_removes(&mut pending_removes, &mut result);
continue;
}
if let Some(content) = line.strip_prefix('+') {
if let Some(removed) = pending_removes.pop() {
let common = common_prefix_len(content, &removed);
let min_len = content.len().min(removed.len());
if min_len >= 3 && common > min_len * 6 / 10 {
result.push(format!("[user~] {}", content));
} else {
result.push(format!("[user-] {}", removed));
result.push(format!("[user+] {}", content));
}
} else {
result.push(format!("[user+] {}", content));
}
} else if let Some(content) = line.strip_prefix('-') {
pending_removes.push(content.to_string());
} else if let Some(content) = line.strip_prefix(' ') {
flush_removes(&mut pending_removes, &mut result);
result.push(format!("[agent] {}", content));
} else if !line.is_empty() {
flush_removes(&mut pending_removes, &mut result);
result.push(format!("[agent] {}", line));
}
}
flush_removes(&mut pending_removes, &mut result);
if result.is_empty() {
None
} else {
Some(result.join("\n"))
}
}
fn flush_removes(pending: &mut Vec<String>, result: &mut Vec<String>) {
for removed in pending.drain(..) {
result.push(format!("[user-] {}", removed));
}
}
fn common_prefix_len(a: &str, b: &str) -> usize {
a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
}
const BUILTIN_COMMAND_NAMES: &[&str] = &[
"/help", "/model", "/clear", "/compact", "/cost", "/login", "/logout",
"/status", "/config", "/memory", "/review", "/bug", "/fast", "/slow",
"/permissions", "/terminal-setup", "/doctor", "/init", "/pr-comments",
"/vim", "/diff", "/undo", "/resume", "/listen", "/mcp", "/approved-tools",
"/add-dir", "/release-notes", "/hooks", "/btw",
];
pub fn is_builtin_command(cmd: &str) -> bool {
let cmd_name = cmd.split_whitespace().next().unwrap_or("");
BUILTIN_COMMAND_NAMES.contains(&cmd_name)
}
pub struct ParsedSlashCommands {
pub skill_commands: Vec<String>,
pub builtin_commands: Vec<String>,
}
pub fn parse_slash_commands_classified(diff: &str) -> ParsedSlashCommands {
let commands = parse_slash_commands(diff);
let mut skill_commands = Vec::new();
let mut builtin_commands = Vec::new();
for cmd in commands {
if is_builtin_command(&cmd) {
builtin_commands.push(cmd);
} else {
skill_commands.push(cmd);
}
}
ParsedSlashCommands { skill_commands, builtin_commands }
}
pub fn parse_slash_commands(diff: &str) -> Vec<String> {
let mut commands = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue; }
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
continue; }
}
}
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
if in_fence {
continue;
}
if content.starts_with('>') {
continue;
}
if !content.starts_with('/') {
continue;
}
if !content[1..].starts_with(|c: char| c.is_ascii_alphabetic()) {
continue;
}
commands.push(content.trim_end().to_string());
}
commands
}
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)?;
let mut stable_count = 0u32;
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)
);
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;
}
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 {
return true;
}
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 {
let mut truncated = max;
while truncated > 0 && !s.is_char_boundary(truncated) {
truncated -= 1;
}
format!("{}...", &s[..truncated])
}
}
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
}
}
const APPROVAL_WORDS: &[&str] = &[
"go", "yes", "do", "ok", "continue", "approve", "approved", "y", "yep", "yeah", "sure",
"proceed", "lgtm",
];
pub fn classify_diff(diff_text: &str) -> DiffClassification {
let mut added: Vec<String> = Vec::new();
let mut removed: Vec<String> = Vec::new();
let mut added_block_count = 0usize;
let mut in_added_block = false;
for line in diff_text.lines() {
if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
in_added_block = false;
continue;
}
if let Some(content) = line.strip_prefix('+') {
added.push(content.to_string());
if !in_added_block {
added_block_count += 1;
in_added_block = true;
}
} else if let Some(content) = line.strip_prefix('-') {
removed.push(content.to_string());
in_added_block = false;
} else {
in_added_block = false;
}
}
let added_content: Vec<&str> = added
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let removed_content: Vec<&str> = removed
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if is_boundary_artifact(&added_content, &removed_content) {
return DiffClassification {
diff_type: DiffType::BoundaryArtifact,
diff_type_reason: "only boundary marker or (HEAD) changes".into(),
};
}
if added_content.len() == 1 && removed_content.is_empty() {
let word = added_content[0].to_lowercase();
let word_clean = word.trim_end_matches(|c: char| c.is_ascii_punctuation());
if APPROVAL_WORDS.contains(&word_clean) {
return DiffClassification {
diff_type: DiffType::Approval,
diff_type_reason: format!("single approval word: \"{}\"", added_content[0]),
};
}
}
if added_content.len() == 1 && removed_content.is_empty() && added_content[0].ends_with('?')
{
return DiffClassification {
diff_type: DiffType::SimpleQuestion,
diff_type_reason: format!(
"single question: \"{}\"",
truncate_for_reason(added_content[0])
),
};
}
if !added_content.is_empty()
&& !removed_content.is_empty()
&& is_annotation(&added_content, &removed_content)
{
return DiffClassification {
diff_type: DiffType::Annotation,
diff_type_reason: "inline edit to existing content".into(),
};
}
if added_content.is_empty() && !removed_content.is_empty() {
return DiffClassification {
diff_type: DiffType::StructuralChange,
diff_type_reason: format!("{} lines removed, no additions", removed_content.len()),
};
}
let has_separator = added.iter().any(|l| l.trim() == "---");
if has_separator && added_content.len() >= 2 {
let section_count = added_content
.split(|l| *l == "---")
.filter(|s| !s.is_empty())
.count();
if section_count >= 2 {
return DiffClassification {
diff_type: DiffType::MultiTopic,
diff_type_reason: format!("{} topics separated by ---", section_count),
};
}
}
if added_block_count >= 2 && added_content.len() >= 2 {
return DiffClassification {
diff_type: DiffType::MultiTopic,
diff_type_reason: format!("{} distinct added blocks", added_block_count),
};
}
DiffClassification {
diff_type: DiffType::ContentAddition,
diff_type_reason: format!(
"{} lines added{}",
added_content.len(),
if !removed_content.is_empty() {
format!(", {} removed", removed_content.len())
} else {
String::new()
}
),
}
}
fn is_boundary_artifact(added: &[&str], removed: &[&str]) -> bool {
let is_boundary_line = |line: &str| -> bool {
line.contains("(HEAD)")
|| line.contains("agent:boundary:")
|| line.is_empty()
};
if added.is_empty() && removed.is_empty() {
return false;
}
if added.iter().all(|l| is_boundary_line(l)) && removed.iter().all(|l| is_boundary_line(l)) {
return true;
}
if added.len() == removed.len() {
return added.iter().zip(removed.iter()).all(|(a, r)| {
let a_clean = a.replace("(HEAD)", "").trim().to_string();
let r_clean = r.replace("(HEAD)", "").trim().to_string();
if a_clean == r_clean {
return true;
}
a.contains("agent:boundary:") && r.contains("agent:boundary:")
});
}
false
}
fn is_annotation(added: &[&str], removed: &[&str]) -> bool {
if added.len() != removed.len() {
return false;
}
added.iter().zip(removed.iter()).all(|(a, r)| {
a.starts_with(r)
|| {
let min_len = a.len().min(r.len());
if min_len < 3 {
return false;
}
let common = a
.chars()
.zip(r.chars())
.take_while(|(a, b)| a == b)
.count();
common > min_len * 6 / 10
}
})
}
fn truncate_for_reason(s: &str) -> &str {
if s.len() <= 80 {
s
} else {
&s[..80]
}
}
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 truncated_single_chars() {
assert!(looks_truncated("A"));
assert!(looks_truncated("S"));
assert!(looks_truncated("1"));
assert!(looks_truncated("y"));
}
#[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");
}
fn make_diff(added: &[&str], removed: &[&str]) -> String {
let mut lines = vec!["--- snapshot", "+++ document", "@@ -1,5 +1,5 @@"];
for r in removed {
lines.push(&r);
}
lines.push(" context line");
for a in added {
lines.push(&a);
}
lines.join("\n")
}
#[test]
fn classify_approval() {
let diff = make_diff(&["+go"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::Approval);
assert!(c.diff_type_reason.contains("go"));
}
#[test]
fn classify_approval_case_insensitive() {
let diff = make_diff(&["+Yes"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::Approval);
}
#[test]
fn classify_simple_question() {
let diff = make_diff(&["+what is the release process?"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::SimpleQuestion);
}
#[test]
fn classify_boundary_artifact() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-### Re: Something (HEAD)\n+### Re: Something\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
}
#[test]
fn classify_boundary_uuid() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-<!-- agent:boundary:abc123 -->\n+<!-- agent:boundary:def456 -->\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
}
#[test]
fn classify_structural_change() {
let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,3 @@\n context\n-removed line one\n-removed line two\n context\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::StructuralChange);
}
#[test]
fn classify_multi_topic() {
let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,7 @@\n context\n+first topic\n context middle\n+second topic\n context end\n";
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::MultiTopic);
}
#[test]
fn classify_multi_topic_with_separator() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n context\n+question one?\n+---\n+do something else\n context end\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::MultiTopic);
}
#[test]
fn classify_content_addition() {
let diff = make_diff(&["+implement the feature using Rust"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::ContentAddition);
}
#[test]
fn classify_annotation() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n context\n-The fix is deployed\n+The fix is deployed: confirmed working in prod\n context\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::Annotation);
}
#[test]
fn classify_approval_in_sentence_is_content() {
let diff = make_diff(&["+let's go ahead and implement the feature"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::ContentAddition);
}
#[test]
fn classify_single_separator_not_multi_topic() {
let diff = make_diff(&["+---"], &[]);
let c = classify_diff(&diff);
assert_ne!(c.diff_type, DiffType::MultiTopic);
}
#[test]
fn classify_question_mark_in_multiline_is_content() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n context\n+first line of paragraph\n+is this a question?\n context\n";
let c = classify_diff(diff);
assert_ne!(c.diff_type, DiffType::SimpleQuestion);
}
#[test]
fn annotate_diff_additions() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n context line\n+new user line\n more context\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[user+] new user line"));
assert!(annotated.contains("[agent] context line"));
}
#[test]
fn annotate_diff_removals() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,2 @@\n context\n-removed line\n context\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[user-] removed line"));
}
#[test]
fn annotate_diff_modifications() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n context\n-The fix is deployed\n+The fix is deployed: confirmed in prod\n context\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[user~] The fix is deployed: confirmed in prod"));
assert!(!annotated.contains("[user-] The fix is deployed"));
}
#[test]
fn annotate_diff_context() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n line one\n line two\n+added\n line three\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[agent] line one"));
assert!(annotated.contains("[agent] line two"));
assert!(annotated.contains("[agent] line three"));
}
#[test]
fn annotate_diff_empty() {
let diff = "--- snapshot\n+++ document\n";
assert!(annotate_diff(diff).is_none());
}
#[test]
fn parse_slash_commands_simple() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/clear"]);
}
#[test]
fn parse_slash_commands_with_args() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+/agent-doc foo.md\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/agent-doc foo.md"]);
}
#[test]
fn parse_slash_commands_ignores_fenced() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_ignores_blockquote() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_ignores_context_lines() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n /clear\n context\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_ignores_removed_lines() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,1 @@\n-/clear\n context\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_requires_letter_after_slash() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/ foo\n+//comment\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_multiple() {
let diff =
"--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/clear\n+/agent-doc foo.md\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/clear", "/agent-doc foo.md"]);
}
}