Skip to main content

termesh_agent/
proposal.rs

1//! Turning an agent's diff into reviewable hunks (ADR-0007 §5).
2//!
3//! ACP does not hand us range edits. It hands us **whole-file before-and-after text**
4//! (`ToolCallContent::Diff { old_text, new_text }`), so the range edits ARCHITECTURE.md
5//! §9.3 originally assumed have to be *derived*. That derivation is here.
6//!
7//! The other half of the problem is anchoring. `old_text` is the file as the agent saw
8//! it, which is not necessarily any revision of ours — the human has probably typed
9//! since. Rather than reaching into undo history for the intervening transactions, we
10//! synthesise them: diffing the agent's base against the current buffer produces exactly
11//! the `ChangeSet` describing "what changed underneath this proposal", and ADR-0006's
12//! machinery then carries the hunks across it or marks them conflicted. One diff routine
13//! serves both jobs.
14
15use std::path::PathBuf;
16
17use similar::{DiffTag, TextDiff};
18use termesh_core::ProposalId;
19use termesh_editor::{ChangeSet, ConflictReason, HunkState, Version};
20
21/// One contiguous change the human accepts or rejects on its own.
22///
23/// `start..end` is a char range in the document the proposal was authored against;
24/// `text` replaces it. A pure insertion has `start == end`, a pure deletion has empty
25/// `text` — the two cases ARCHITECTURE.md §9.3's "file/range edits" glossed over.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Hunk {
28    pub start: usize,
29    pub end: usize,
30    pub text: String,
31    pub state: HunkState,
32}
33
34impl Hunk {
35    pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
36        Self { start, end, text: text.into(), state: HunkState::Clean }
37    }
38
39    pub fn is_insertion(&self) -> bool {
40        self.start == self.end
41    }
42
43    pub fn is_deletion(&self) -> bool {
44        self.text.is_empty()
45    }
46}
47
48/// A set of edits to one file, awaiting review.
49///
50/// **This is the authoritative record of a pending review.** Hunk decorations in the
51/// buffer are a *projection* of it, rebuilt by [`Self::refresh`] rather than maintained
52/// in parallel — two mechanisms tracking the same state is how you end up with a proposal
53/// that reads clean in one place and conflicted in the other.
54#[derive(Debug, Clone)]
55pub struct EditProposal {
56    pub id: ProposalId,
57    pub path: PathBuf,
58    /// The buffer revision this was anchored to, when we could establish one (§5).
59    /// `None` means the agent read the file by some other means and we anchored by
60    /// content instead.
61    pub base_version: Option<Version>,
62    /// The file as the agent saw it. Immutable — ADR-0006 §3 keeps the original so the
63    /// agent can always be re-asked with the exact context it authored against, and so
64    /// [`Self::refresh`] can recompute from a fixed point rather than accumulating drift.
65    pub base_text: String,
66    /// The file as the agent proposed it. Immutable, same reasons.
67    pub proposed_text: String,
68    pub hunks: Vec<Hunk>,
69}
70
71impl EditProposal {
72    /// Build a proposal from a whole-file diff, anchored onto `current_text`.
73    pub fn new(
74        id: ProposalId,
75        path: PathBuf,
76        base_version: Option<Version>,
77        base_text: String,
78        proposed_text: String,
79        current_text: &str,
80    ) -> Self {
81        let mut proposal =
82            Self { id, path, base_version, base_text, proposed_text, hunks: Vec::new() };
83        proposal.refresh(current_text);
84        proposal
85    }
86
87    /// Recompute the hunks against the buffer as it stands now.
88    ///
89    /// Idempotent, and derived from the immutable original every time rather than from
90    /// the last result, so repeated calls cannot accumulate error. Cheap enough to run on
91    /// every edit for realistic proposals; ADR-0006 §3 notes it can be made lazy if that
92    /// ever stops being true.
93    pub fn refresh(&mut self, current_text: &str) {
94        self.hunks = hunks_from_diff(&self.base_text, &self.proposed_text);
95        rebase_hunks(&mut self.hunks, &self.base_text, current_text);
96    }
97    /// Hunks that would actually apply if accepted right now.
98    pub fn applicable(&self) -> impl Iterator<Item = &Hunk> {
99        self.hunks.iter().filter(|h| h.state.is_applicable())
100    }
101
102    pub fn has_conflicts(&self) -> bool {
103        self.hunks.iter().any(|h| matches!(h.state, HunkState::Conflicted(_)))
104    }
105
106    /// Whether there is nothing left to review — every hunk applied or resolved itself.
107    pub fn is_settled(&self) -> bool {
108        self.hunks.iter().all(|h| h.state == HunkState::Satisfied)
109    }
110}
111
112/// Char offset of the start of each line, plus a sentinel for the end of the text.
113///
114/// `split_inclusive('\n')` matches `similar`'s line tokenizer for `\n`-only text, which
115/// is what buffers hold — `Buffer::from_text` normalises CRLF on the way in, so the two
116/// cannot disagree here.
117fn line_offsets(text: &str) -> Vec<usize> {
118    let mut offsets = Vec::new();
119    let mut acc = 0;
120    for line in text.split_inclusive('\n') {
121        offsets.push(acc);
122        acc += line.chars().count();
123    }
124    offsets.push(acc);
125    offsets
126}
127
128/// Derive review hunks from a whole-file before/after pair.
129///
130/// Line granularity, because that is the unit a human reviews in — and it is what makes
131/// a hunk correspond to something you can point at on screen.
132pub fn hunks_from_diff(old: &str, new: &str) -> Vec<Hunk> {
133    let diff = TextDiff::from_lines(old, new);
134    let offsets = line_offsets(old);
135    let new_lines: Vec<&str> = new.split_inclusive('\n').collect();
136
137    diff.ops()
138        .iter()
139        .filter_map(|op| {
140            let (tag, old_range, new_range) = op.as_tag_tuple();
141            if tag == DiffTag::Equal {
142                return None;
143            }
144            Some(Hunk::new(
145                offsets[old_range.start],
146                offsets[old_range.end],
147                new_lines[new_range].concat(),
148            ))
149        })
150        .collect()
151}
152
153/// The single change that applies `hunks` to a document of `len_before` chars.
154///
155/// Hunks must be non-overlapping and are applied in document order, so accepting several
156/// at once is one transaction — and therefore one undo step (ADR-0006 §6).
157pub fn changeset_from_hunks(hunks: &[&Hunk], len_before: usize) -> ChangeSet {
158    let mut ordered: Vec<&&Hunk> = hunks.iter().collect();
159    ordered.sort_by_key(|h| (h.start, h.end));
160
161    let mut builder = ChangeSet::builder(len_before);
162    let mut at = 0;
163    for hunk in ordered {
164        debug_assert!(hunk.start >= at, "hunks overlap: {at} > {}", hunk.start);
165        builder.retain(hunk.start.saturating_sub(at));
166        builder.delete(hunk.end - hunk.start);
167        builder.insert(hunk.text.clone());
168        at = hunk.end;
169    }
170    builder.build()
171}
172
173/// Carry hunks authored against `base_text` onto `current_text`.
174///
175/// The agent's base is not necessarily a revision we hold, so rather than replaying undo
176/// history we *synthesise* the intervening change by diffing base against current. The
177/// result is an ordinary [`ChangeSet`], which means ADR-0006 §4's overlap rules apply
178/// unchanged — including that a hunk the human edited inside is conflicted rather than
179/// silently applied over their work.
180pub fn rebase_hunks(hunks: &mut [Hunk], base_text: &str, current_text: &str) {
181    if base_text == current_text {
182        return; // nothing moved
183    }
184
185    // What the human did, in hunk form. Both these and the agent's hunks are offsets
186    // into `base_text`, which is what makes case 5 below an exact comparison.
187    let human = hunks_from_diff(base_text, current_text);
188    let catchup =
189        changeset_from_hunks(&human.iter().collect::<Vec<_>>(), base_text.chars().count());
190
191    for hunk in hunks.iter_mut() {
192        // ADR-0006 §4 case 5, checked *before* conflict classification: making the
193        // identical change destroys the anchor, so from position mapping alone it is
194        // indistinguishable from a deletion.
195        //
196        // Comparing whole hunks rather than scanning the document for the replacement
197        // text matters. A hunk inserting something as common as "}\n" would match a
198        // window almost anywhere, and `Satisfied` means the change disappears with no
199        // conflict marker and nothing on screen — the exact inversion of "never silently
200        // destroy". An identical hunk is proof; a matching window is a coincidence.
201        if human.iter().any(|h| h.start == hunk.start && h.end == hunk.end && h.text == hunk.text) {
202            hunk.state = HunkState::Satisfied;
203            continue;
204        }
205
206        match ConflictReason::from_effect(catchup.touches(hunk.start, hunk.end)) {
207            Some(reason) => hunk.state = HunkState::Conflicted(reason),
208            None => {
209                hunk.start = catchup.map_pos(hunk.start, termesh_editor::Assoc::After);
210                hunk.end = catchup.map_pos(hunk.end, termesh_editor::Assoc::After);
211            }
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn texts(hunks: &[Hunk]) -> Vec<(usize, usize, &str)> {
221        hunks.iter().map(|h| (h.start, h.end, h.text.as_str())).collect()
222    }
223
224    /// Apply every clean hunk and read the document back.
225    fn apply(base: &str, hunks: &[Hunk]) -> String {
226        let clean: Vec<&Hunk> = hunks.iter().filter(|h| h.state.is_applicable()).collect();
227        let cs = changeset_from_hunks(&clean, base.chars().count());
228        cs.apply(&ropey::Rope::from_str(base)).to_string()
229    }
230
231    // --- deriving hunks from a whole-file diff -------------------------------------
232
233    #[test]
234    fn an_unchanged_file_yields_no_hunks() {
235        assert!(hunks_from_diff("a\nb\n", "a\nb\n").is_empty());
236    }
237
238    #[test]
239    fn a_changed_line_becomes_one_hunk_over_its_char_range() {
240        let old = "one\ntwo\nthree\n";
241        let hunks = hunks_from_diff(old, "one\nTWO\nthree\n");
242        assert_eq!(texts(&hunks), [(4, 8, "TWO\n")]);
243        assert_eq!(apply(old, &hunks), "one\nTWO\nthree\n");
244    }
245
246    #[test]
247    fn an_inserted_line_is_a_zero_width_hunk() {
248        let old = "one\nthree\n";
249        let hunks = hunks_from_diff(old, "one\ntwo\nthree\n");
250        assert_eq!(hunks.len(), 1);
251        assert!(hunks[0].is_insertion(), "nothing is replaced, so the range is empty");
252        assert_eq!(apply(old, &hunks), "one\ntwo\nthree\n");
253    }
254
255    #[test]
256    fn a_deleted_line_is_a_hunk_with_no_replacement() {
257        let old = "one\ntwo\nthree\n";
258        let hunks = hunks_from_diff(old, "one\nthree\n");
259        assert_eq!(hunks.len(), 1);
260        assert!(hunks[0].is_deletion());
261        assert_eq!(apply(old, &hunks), "one\nthree\n");
262    }
263
264    #[test]
265    fn separate_edits_become_separate_hunks() {
266        // The reason review is per-hunk: two unrelated changes, two decisions.
267        let old = "one\ntwo\nthree\nfour\n";
268        let hunks = hunks_from_diff(old, "ONE\ntwo\nthree\nFOUR\n");
269        assert_eq!(hunks.len(), 2);
270        assert_eq!(apply(old, &hunks), "ONE\ntwo\nthree\nFOUR\n");
271    }
272
273    #[test]
274    fn accepting_only_one_hunk_leaves_the_other_alone() {
275        let old = "one\ntwo\nthree\nfour\n";
276        let hunks = hunks_from_diff(old, "ONE\ntwo\nthree\nFOUR\n");
277
278        let cs = changeset_from_hunks(&[&hunks[0]], old.chars().count());
279        assert_eq!(cs.apply(&ropey::Rope::from_str(old)).to_string(), "ONE\ntwo\nthree\nfour\n");
280    }
281
282    #[test]
283    fn creating_a_file_from_nothing_is_one_insertion() {
284        let hunks = hunks_from_diff("", "hello\n");
285        assert_eq!(texts(&hunks), [(0, 0, "hello\n")]);
286        assert_eq!(apply("", &hunks), "hello\n");
287    }
288
289    #[test]
290    fn a_file_without_a_trailing_newline_round_trips() {
291        let old = "one\ntwo";
292        let hunks = hunks_from_diff(old, "one\nTWO");
293        assert_eq!(apply(old, &hunks), "one\nTWO");
294    }
295
296    #[test]
297    fn multibyte_lines_produce_char_offsets_not_byte_offsets() {
298        let old = "héllo\nwörld\n";
299        let hunks = hunks_from_diff(old, "héllo\nWORLD\n");
300        assert_eq!(hunks[0].start, 6, "6 chars, not 7 bytes");
301        assert_eq!(apply(old, &hunks), "héllo\nWORLD\n");
302    }
303
304    // --- rebasing onto a buffer the human has been typing in ------------------------
305
306    #[test]
307    fn an_untouched_proposal_needs_no_rebasing() {
308        let base = "one\ntwo\n";
309        let mut hunks = hunks_from_diff(base, "one\nTWO\n");
310        let before = hunks.clone();
311        rebase_hunks(&mut hunks, base, base);
312        assert_eq!(hunks, before);
313    }
314
315    /// The headline case: the human types elsewhere while the agent is thinking, and the
316    /// proposal still lands on the right code.
317    #[test]
318    fn a_hunk_rides_over_an_edit_made_above_it() {
319        let base = "one\ntwo\nthree\n";
320        let current = "zero\none\ntwo\nthree\n"; // human added a line at the top
321        let mut hunks = hunks_from_diff(base, "one\ntwo\nTHREE\n");
322
323        rebase_hunks(&mut hunks, base, current);
324
325        assert_eq!(hunks[0].state, HunkState::Clean);
326        assert_eq!(apply(current, &hunks), "zero\none\ntwo\nTHREE\n");
327    }
328
329    /// ADR-0006 §4 case 2, end to end: never silently destroy what the human wrote.
330    #[test]
331    fn a_hunk_the_human_edited_inside_conflicts() {
332        let base = "one\ntwo\nthree\n";
333        let current = "one\ntwo EDITED\nthree\n";
334        let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
335
336        rebase_hunks(&mut hunks, base, current);
337
338        assert!(matches!(hunks[0].state, HunkState::Conflicted(_)), "got {:?}", hunks[0].state);
339        assert_eq!(apply(current, &hunks), current, "and nothing is applied");
340    }
341
342    /// ADR-0006 §4 case 5 — the one that stops the loop feeling stupid.
343    #[test]
344    fn a_change_the_human_already_made_resolves_itself() {
345        let base = "one\ntwo\nthree\n";
346        let current = "one\nTWO\nthree\n"; // human made the agent's exact change
347        let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
348
349        rebase_hunks(&mut hunks, base, current);
350
351        assert_eq!(hunks[0].state, HunkState::Satisfied, "not a conflict — already done");
352        assert_eq!(apply(current, &hunks), current, "and applying it would not duplicate it");
353    }
354
355    /// A window scan would call this satisfied and drop the change silently: `}` lines
356    /// are everywhere, so "the replacement text appears at the mapped position" is a
357    /// coincidence, not proof. Only an identical hunk is proof.
358    #[test]
359    fn common_replacement_text_elsewhere_does_not_count_as_already_done() {
360        let base = "fn a() {\n}\nfn b() {\n    x\n}\n";
361        // The human deletes the body of `b`, leaving a bare `}` where the agent's
362        // inserted `}` would map to.
363        let current = "fn a() {\n}\nfn b() {\n}\n";
364        // The agent wants to add a line inside `b`.
365        let mut hunks = hunks_from_diff(base, "fn a() {\n}\nfn b() {\n    x\n    y\n}\n");
366
367        rebase_hunks(&mut hunks, base, current);
368
369        assert!(
370            hunks.iter().all(|h| h.state != HunkState::Satisfied),
371            "a coincidental match must not swallow the change: {:?}",
372            hunks.iter().map(|h| h.state).collect::<Vec<_>>()
373        );
374    }
375
376    #[test]
377    fn a_larger_human_edit_covering_the_same_change_conflicts_rather_than_settling() {
378        let base = "one\ntwo\nthree\n";
379        // The human changed the agent's line *and* the one after it.
380        let current = "one\nTWO\nTHREE\n";
381        let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
382
383        rebase_hunks(&mut hunks, base, current);
384        assert!(
385            matches!(hunks[0].state, HunkState::Conflicted(_)),
386            "not an identical change, so it needs a human decision"
387        );
388    }
389
390    #[test]
391    fn one_conflicted_hunk_does_not_invalidate_its_siblings() {
392        // ARCHITECTURE §9.3 requires per-hunk granularity, and this is what it buys.
393        let base = "one\ntwo\nthree\nfour\n";
394        let current = "one\ntwo EDITED\nthree\nfour\n";
395        let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\nFOUR\n");
396        assert_eq!(hunks.len(), 2);
397
398        rebase_hunks(&mut hunks, base, current);
399
400        assert!(matches!(hunks[0].state, HunkState::Conflicted(_)));
401        assert_eq!(hunks[1].state, HunkState::Clean, "the unrelated change still applies");
402        assert_eq!(apply(current, &hunks), "one\ntwo EDITED\nthree\nFOUR\n");
403    }
404
405    #[test]
406    fn a_hunk_whose_lines_were_deleted_conflicts() {
407        let base = "one\ntwo\nthree\n";
408        let current = "one\nthree\n"; // human deleted the line the agent wanted to change
409        let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
410
411        rebase_hunks(&mut hunks, base, current);
412        assert!(matches!(hunks[0].state, HunkState::Conflicted(_)));
413    }
414
415    #[test]
416    fn a_proposal_reports_whether_anything_is_left_to_review() {
417        let base = "one\n";
418        let mut proposal = EditProposal::new(
419            ProposalId::new(1),
420            PathBuf::from("/proj/a.rs"),
421            Some(Version(3)),
422            base.into(),
423            "ONE\n".into(),
424            base,
425        );
426        assert!(!proposal.is_settled());
427        assert!(!proposal.has_conflicts());
428        assert_eq!(proposal.applicable().count(), 1);
429
430        proposal.hunks[0].state = HunkState::Satisfied;
431        assert!(proposal.is_settled());
432        assert_eq!(proposal.applicable().count(), 0);
433    }
434
435    /// The single-owner property: refreshing recomputes from the immutable original, so
436    /// the same buffer state always yields the same verdict no matter how it was reached.
437    #[test]
438    fn refreshing_is_idempotent_and_derived_from_the_original() {
439        let base = "one\ntwo\nthree\n";
440        let mut proposal = EditProposal::new(
441            ProposalId::new(1),
442            PathBuf::from("/a"),
443            None,
444            base.into(),
445            "one\nTWO\nthree\n".into(),
446            base,
447        );
448        assert_eq!(proposal.hunks[0].state, HunkState::Clean);
449
450        // The human edits the same line: the proposal must now conflict.
451        proposal.refresh("one\ntwo EDITED\nthree\n");
452        let conflicted = proposal.hunks.clone();
453        assert!(matches!(conflicted[0].state, HunkState::Conflicted(_)));
454
455        // Running it again changes nothing.
456        proposal.refresh("one\ntwo EDITED\nthree\n");
457        assert_eq!(proposal.hunks, conflicted, "refresh must be idempotent");
458
459        // And undoing their edit brings it back — because it recomputes from the
460        // original rather than from the previous verdict.
461        proposal.refresh(base);
462        assert_eq!(proposal.hunks[0].state, HunkState::Clean, "a conflict is not permanent");
463    }
464}