Skip to main content

microclaw_core/
diff.rs

1//! Unified-diff rendering for file-modifying tools.
2//!
3//! Produces a compact, user-facing unified diff (with `+N -M` stats and a
4//! line cap) so channel adapters and the web UI can show exactly what an
5//! `edit_file` / `write_file` call changed, instead of a bare success line.
6
7use similar::{ChangeTag, TextDiff};
8
9/// Default cap on rendered diff lines (matching the 120-line convention
10/// popularized by Grok Build's diff view).
11pub const DEFAULT_DIFF_MAX_LINES: usize = 120;
12
13/// A rendered unified diff plus its summary stats.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct UnifiedDiff {
16    /// Unified-diff body (hunk headers + context/insert/delete lines), capped
17    /// at the requested line count. No `--- a/…` / `+++ b/…` file header —
18    /// callers render the path themselves.
19    pub text: String,
20    /// Total inserted lines (uncapped — counted before truncation).
21    pub added: usize,
22    /// Total removed lines (uncapped — counted before truncation).
23    pub removed: usize,
24    /// `true` when the rendered body was cut at `max_lines`.
25    pub truncated: bool,
26}
27
28impl UnifiedDiff {
29    /// `+N -M` summary, e.g. `+3 -1`.
30    pub fn stats(&self) -> String {
31        format!("+{} -{}", self.added, self.removed)
32    }
33}
34
35/// Compute a unified diff between `old` and `new`, capped at `max_lines`
36/// rendered lines. Returns `None` when the contents are identical.
37pub fn unified_diff(old: &str, new: &str, max_lines: usize) -> Option<UnifiedDiff> {
38    if old == new {
39        return None;
40    }
41    let diff = TextDiff::from_lines(old, new);
42    let mut added = 0usize;
43    let mut removed = 0usize;
44    for change in diff.iter_all_changes() {
45        match change.tag() {
46            ChangeTag::Insert => added += 1,
47            ChangeTag::Delete => removed += 1,
48            ChangeTag::Equal => {}
49        }
50    }
51
52    let max_lines = max_lines.max(4);
53    let mut lines: Vec<String> = Vec::new();
54    let mut truncated = false;
55    'outer: for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
56        if lines.len() + 1 > max_lines {
57            truncated = true;
58            break;
59        }
60        lines.push(hunk.header().to_string());
61        for change in hunk.iter_changes() {
62            if lines.len() + 1 > max_lines {
63                truncated = true;
64                break 'outer;
65            }
66            let sign = match change.tag() {
67                ChangeTag::Insert => "+",
68                ChangeTag::Delete => "-",
69                ChangeTag::Equal => " ",
70            };
71            let value = change.value();
72            lines.push(format!(
73                "{sign}{}",
74                value.strip_suffix('\n').unwrap_or(value)
75            ));
76        }
77    }
78    if truncated {
79        lines.push(format!("… (diff truncated at {max_lines} lines)"));
80    }
81    Some(UnifiedDiff {
82        text: lines.join("\n"),
83        added,
84        removed,
85        truncated,
86    })
87}
88
89/// Key under which file-modifying tools attach their diff payload to
90/// `ToolResult::metadata`. Shared so the producer (tools) and consumers
91/// (tool executor → `AgentEvent::FileDiff`) cannot silently desync.
92pub const FILE_DIFF_METADATA_KEY: &str = "file_diff";
93
94/// Build the metadata payload for a file edit diff.
95pub fn file_diff_metadata(path: &str, diff: &UnifiedDiff) -> serde_json::Value {
96    serde_json::json!({
97        FILE_DIFF_METADATA_KEY: {
98            "path": path,
99            "diff": diff.text,
100            "added": diff.added,
101            "removed": diff.removed,
102            "truncated": diff.truncated,
103        }
104    })
105}
106
107/// Format a chat-facing message for a file diff: a one-line header with the
108/// path and `+N -M` stats, followed by a fenced ```diff block.
109pub fn format_diff_chat_message(
110    path: &str,
111    diff_text: &str,
112    added: usize,
113    removed: usize,
114) -> String {
115    format!("📝 `{path}` (+{added} -{removed})\n```diff\n{diff_text}\n```")
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn identical_content_yields_none() {
124        assert!(unified_diff("a\nb\n", "a\nb\n", 120).is_none());
125    }
126
127    #[test]
128    fn counts_added_and_removed_lines() {
129        let old = "one\ntwo\nthree\n";
130        let new = "one\n2\nthree\nfour\n";
131        let d = unified_diff(old, new, 120).expect("diff");
132        assert_eq!(d.added, 2); // "2" and "four"
133        assert_eq!(d.removed, 1); // "two"
134        assert_eq!(d.stats(), "+2 -1");
135        assert!(!d.truncated);
136        assert!(d.text.contains("-two"));
137        assert!(d.text.contains("+2"));
138        assert!(d.text.contains("+four"));
139        assert!(d.text.contains("@@"));
140    }
141
142    #[test]
143    fn truncates_at_max_lines() {
144        let old = (0..200).map(|i| format!("line{i}\n")).collect::<String>();
145        let new = (0..200).map(|i| format!("LINE{i}\n")).collect::<String>();
146        let d = unified_diff(&old, &new, 20).expect("diff");
147        assert!(d.truncated);
148        // 20 rendered lines + 1 truncation notice.
149        assert_eq!(d.text.lines().count(), 21);
150        assert!(d.text.ends_with("… (diff truncated at 20 lines)"));
151        // Stats stay uncapped.
152        assert_eq!(d.added, 200);
153        assert_eq!(d.removed, 200);
154    }
155
156    #[test]
157    fn new_file_diff_from_empty() {
158        let d = unified_diff("", "hello\nworld\n", 120).expect("diff");
159        assert_eq!(d.added, 2);
160        assert_eq!(d.removed, 0);
161    }
162
163    #[test]
164    fn chat_message_format() {
165        let msg = format_diff_chat_message("src/x.rs", "@@ -1 +1 @@\n-a\n+b", 1, 1);
166        assert!(msg.starts_with("📝 `src/x.rs` (+1 -1)\n```diff\n"));
167        assert!(msg.ends_with("\n```"));
168    }
169
170    #[test]
171    fn metadata_shape() {
172        let d = unified_diff("a\n", "b\n", 120).unwrap();
173        let meta = file_diff_metadata("f.txt", &d);
174        let fd = &meta[FILE_DIFF_METADATA_KEY];
175        assert_eq!(fd["path"], "f.txt");
176        assert_eq!(fd["added"], 1);
177        assert_eq!(fd["removed"], 1);
178        assert_eq!(fd["truncated"], false);
179    }
180}