Skip to main content

undo_core/
diff.rs

1//! `undo diff` — a PR-style view of exactly what the agent changed.
2//!
3//! undo already holds the *before* state of every file it captured (in the blob
4//! store). Comparing that against the current files produces a real diff — the
5//! reviewable "here's everything the AI did" surface, sourced from undo's own
6//! snapshots rather than git.
7
8use crate::effect::Effect;
9use crate::store::Store;
10use serde::Serialize;
11use similar::{ChangeTag, TextDiff};
12use std::fs;
13use std::path::Path;
14
15/// One changed path in the diff.
16#[derive(Debug, Serialize)]
17pub struct DiffEntry {
18    pub path: String,
19    /// created | modified | deleted | binary | symlink
20    pub status: String,
21    pub added: usize,
22    pub removed: usize,
23    /// Unified-diff text (empty for symlink/binary, which carry a note instead).
24    pub hunk: String,
25}
26
27/// Diff every captured file effect against its current on-disk state.
28pub fn diff_effects(effects: &[Effect], store: &Store) -> Vec<DiffEntry> {
29    let mut out = vec![];
30    for e in effects {
31        match e {
32            Effect::File {
33                path, prev_blob, ..
34            } => {
35                let before = store.get(prev_blob).unwrap_or_default();
36                match fs::read(path) {
37                    Ok(after) if after == before => {} // unchanged since capture
38                    Ok(after) => out.push(make(path, &before, &after, "modified")),
39                    Err(_) => out.push(make(path, &before, &[], "deleted")),
40                }
41            }
42            Effect::PathCreate { path } => {
43                if let Ok(after) = fs::read(path) {
44                    out.push(make(path, &[], &after, "created"));
45                }
46            }
47            Effect::Symlink { path, target } => out.push(DiffEntry {
48                path: path.display().to_string(),
49                status: "symlink".into(),
50                added: 0,
51                removed: 0,
52                hunk: format!("→ {}", target.display()),
53            }),
54            // Directories are reflected by their files; http/exec are surfaced
55            // by status/compensate, not the file diff.
56            _ => {}
57        }
58    }
59    out
60}
61
62fn make(path: &Path, before: &[u8], after: &[u8], status: &str) -> DiffEntry {
63    let display = path.display().to_string();
64    let (Ok(b), Ok(a)) = (std::str::from_utf8(before), std::str::from_utf8(after)) else {
65        return DiffEntry {
66            path: display,
67            status: "binary".into(),
68            added: 0,
69            removed: 0,
70            hunk: "(binary file changed)".into(),
71        };
72    };
73
74    let td = TextDiff::from_lines(b, a);
75    let mut added = 0;
76    let mut removed = 0;
77    for c in td.iter_all_changes() {
78        match c.tag() {
79            ChangeTag::Insert => added += 1,
80            ChangeTag::Delete => removed += 1,
81            ChangeTag::Equal => {}
82        }
83    }
84    let hunk = td.unified_diff().context_radius(3).to_string();
85
86    DiffEntry {
87        path: display,
88        status: status.into(),
89        added,
90        removed,
91        hunk,
92    }
93}