Skip to main content

lean_ctx/core/
delta_response.rs

1//! Delta responses for re-reads after edits (#1316).
2//!
3//! When a file is re-read after the agent edited it, deliver only the
4//! changed lines instead of the full file. This eliminates redundant
5//! delivery of unchanged content the agent already has in context.
6
7/// A minimal unified-diff-style delta between two versions.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct DeltaResponse {
10    pub path: String,
11    pub hunks: Vec<Hunk>,
12    pub lines_changed: usize,
13    pub lines_unchanged: usize,
14}
15
16/// A single change hunk.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Hunk {
19    pub old_start: usize,
20    pub new_start: usize,
21    pub context_before: Vec<String>,
22    pub removed: Vec<String>,
23    pub added: Vec<String>,
24    pub context_after: Vec<String>,
25}
26
27impl DeltaResponse {
28    /// Format as a compact delta for the agent.
29    pub fn format(&self) -> String {
30        if self.hunks.is_empty() {
31            return format!("[unchanged: {} — no edits detected]", self.path);
32        }
33
34        let mut out = format!(
35            "Δ {} ({} lines changed, {} unchanged)\n",
36            self.path, self.lines_changed, self.lines_unchanged
37        );
38
39        for hunk in &self.hunks {
40            out.push_str(&format!(
41                "@@ -{},{} +{} @@\n",
42                hunk.old_start,
43                hunk.removed.len(),
44                hunk.new_start
45            ));
46            for line in &hunk.context_before {
47                out.push_str(&format!(" {line}\n"));
48            }
49            for line in &hunk.removed {
50                out.push_str(&format!("-{line}\n"));
51            }
52            for line in &hunk.added {
53                out.push_str(&format!("+{line}\n"));
54            }
55            for line in &hunk.context_after {
56                out.push_str(&format!(" {line}\n"));
57            }
58        }
59
60        out
61    }
62
63    /// Token savings from delivering delta instead of full content.
64    pub fn savings_ratio(&self) -> f64 {
65        let total = self.lines_changed + self.lines_unchanged;
66        if total == 0 {
67            return 0.0;
68        }
69        self.lines_unchanged as f64 / total as f64
70    }
71}
72
73/// Find the next point where `old[skip_old..]` and `new[skip_new..]` agree.
74/// Returns `(old_skip, new_skip)` — the number of lines to consume from each.
75fn find_sync_point(old: &[&str], new: &[&str], max_look: usize) -> Option<(usize, usize)> {
76    let limit_o = old.len().min(max_look);
77    let limit_n = new.len().min(max_look);
78
79    for dist in 1..=(limit_o + limit_n) {
80        for skip_o in 0..=dist.min(limit_o) {
81            let skip_n = dist - skip_o;
82            if skip_n > limit_n {
83                continue;
84            }
85            if skip_o < old.len() && skip_n < new.len() && old.get(skip_o) == new.get(skip_n) {
86                return Some((skip_o, skip_n));
87            }
88        }
89    }
90
91    None
92}
93
94/// Compute a delta between `old_content` and `new_content`.
95///
96/// Uses a simple line-diff algorithm: identifies changed regions
97/// with minimal context lines around each change.
98pub fn compute_delta(
99    path: &str,
100    old_content: &str,
101    new_content: &str,
102    context_lines: usize,
103) -> DeltaResponse {
104    let old_lines: Vec<&str> = old_content.lines().collect();
105    let new_lines: Vec<&str> = new_content.lines().collect();
106
107    if old_lines == new_lines {
108        return DeltaResponse {
109            path: path.to_string(),
110            hunks: Vec::new(),
111            lines_changed: 0,
112            lines_unchanged: old_lines.len(),
113        };
114    }
115
116    let mut hunks = Vec::new();
117    let mut i = 0;
118    let mut j = 0;
119    let mut lines_changed = 0;
120
121    while i < old_lines.len() || j < new_lines.len() {
122        if i < old_lines.len() && j < new_lines.len() && old_lines[i] == new_lines[j] {
123            i += 1;
124            j += 1;
125            continue;
126        }
127
128        let old_start = i + 1;
129        let new_start = j + 1;
130
131        let ctx_start = i.saturating_sub(context_lines);
132        let context_before: Vec<String> = old_lines[ctx_start..i]
133            .iter()
134            .map(std::string::ToString::to_string)
135            .collect();
136
137        let mut removed = Vec::new();
138        let mut added = Vec::new();
139
140        // Find the next sync point where both sequences agree again.
141        // Look ahead up to 50 lines in both directions to find a match.
142        let sync = find_sync_point(&old_lines[i..], &new_lines[j..], 50);
143        let (old_skip, new_skip) = sync.unwrap_or((old_lines.len() - i, new_lines.len() - j));
144
145        for line in &old_lines[i..i + old_skip] {
146            removed.push(line.to_string());
147        }
148        i += old_skip;
149
150        for line in &new_lines[j..j + new_skip] {
151            added.push(line.to_string());
152        }
153        j += new_skip;
154
155        let ctx_end = (i + context_lines).min(old_lines.len());
156        let context_after: Vec<String> = old_lines[i..ctx_end]
157            .iter()
158            .map(std::string::ToString::to_string)
159            .collect();
160
161        lines_changed += removed.len() + added.len();
162        hunks.push(Hunk {
163            old_start,
164            new_start,
165            context_before,
166            removed,
167            added,
168            context_after,
169        });
170    }
171
172    let lines_unchanged = old_lines.len().saturating_sub(lines_changed);
173
174    DeltaResponse {
175        path: path.to_string(),
176        hunks,
177        lines_changed,
178        lines_unchanged,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn no_changes_returns_empty_delta() {
188        let delta = compute_delta("test.rs", "line1\nline2", "line1\nline2", 2);
189        assert!(delta.hunks.is_empty());
190        assert_eq!(delta.lines_unchanged, 2);
191        assert_eq!(delta.format(), "[unchanged: test.rs — no edits detected]");
192    }
193
194    #[test]
195    fn single_line_change() {
196        let delta = compute_delta("test.rs", "a\nb\nc", "a\nB\nc", 1);
197        assert_eq!(delta.hunks.len(), 1);
198        assert_eq!(delta.hunks[0].removed, vec!["b"]);
199        assert_eq!(delta.hunks[0].added, vec!["B"]);
200    }
201
202    #[test]
203    fn format_includes_delta_marker() {
204        let delta = compute_delta("src/main.rs", "old line", "new line", 0);
205        let formatted = delta.format();
206        assert!(formatted.contains("Δ src/main.rs"));
207        assert!(formatted.contains("-old line"));
208        assert!(formatted.contains("+new line"));
209    }
210
211    #[test]
212    fn savings_ratio_high_for_small_change() {
213        let old = (0..100)
214            .map(|i| format!("line {i}"))
215            .collect::<Vec<_>>()
216            .join("\n");
217        let mut new_lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
218        new_lines[50] = "CHANGED LINE".to_string();
219        let new = new_lines.join("\n");
220
221        let delta = compute_delta("big.rs", &old, &new, 2);
222        assert!(
223            delta.savings_ratio() > 0.90,
224            "savings should be >90% for 1/100 change"
225        );
226    }
227}