Skip to main content

clankerdiff_core/
review.rs

1//! Structured review comments, reconciliation, and agent-facing output.
2
3use crate::{
4    DiffDocument, DiffScope, DiffSide, FileStatus, Fingerprint, LineAnchor, PatchLineKind, RepoPath,
5};
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{HashMap, HashSet},
9    fmt::Write,
10};
11
12/// The captured source context for a comment.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct CommentContext {
15    pub path: RepoPath,
16    pub side: DiffSide,
17    pub line_number: Option<usize>,
18    pub line_kind: PatchLineKind,
19    pub line_text: String,
20}
21
22/// A review comment retained even when its source disappears.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ReviewComment {
25    pub id: u64,
26    pub anchor: LineAnchor,
27    pub body: String,
28    pub context: CommentContext,
29    pub outdated: bool,
30}
31
32/// Mutable collection of review comments.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Review {
35    comments: Vec<ReviewComment>,
36    next_id: u64,
37}
38
39impl Review {
40    /// Adds a comment and returns its stable review-local ID.
41    pub fn add_comment(&mut self, anchor: LineAnchor, body: impl Into<String>) -> u64 {
42        self.add_comment_with_context(anchor, String::new(), body)
43    }
44
45    pub fn add_comment_with_context(
46        &mut self,
47        anchor: LineAnchor,
48        line_text: impl Into<String>,
49        body: impl Into<String>,
50    ) -> u64 {
51        let id = self.next_id;
52        self.next_id = self.next_id.saturating_add(1);
53        let context = CommentContext {
54            path: anchor.path.clone(),
55            side: anchor.side,
56            line_number: anchor.line_number(),
57            line_kind: anchor.kind,
58            line_text: line_text.into(),
59        };
60        self.comments.push(ReviewComment {
61            id,
62            anchor,
63            body: body.into(),
64            context,
65            outdated: false,
66        });
67        id
68    }
69
70    /// Changes a comment body, returning whether the ID existed.
71    pub fn edit_comment(&mut self, id: u64, body: impl Into<String>) -> bool {
72        if let Some(comment) = self.comments.iter_mut().find(|comment| comment.id == id) {
73            comment.body = body.into();
74            return true;
75        }
76        false
77    }
78
79    /// Removes a comment, returning it if present.
80    pub fn remove_comment(&mut self, id: u64) -> Option<ReviewComment> {
81        let index = self.comments.iter().position(|comment| comment.id == id)?;
82        Some(self.comments.remove(index))
83    }
84
85    #[must_use]
86    pub fn comment(&self, id: u64) -> Option<&ReviewComment> {
87        self.comments.iter().find(|comment| comment.id == id)
88    }
89
90    /// Returns whether the review has no comments.
91    #[must_use]
92    pub fn is_empty(&self) -> bool {
93        self.comments.is_empty()
94    }
95
96    /// Returns the number of comments.
97    #[must_use]
98    pub fn len(&self) -> usize {
99        self.comments.len()
100    }
101
102    #[must_use]
103    pub fn outdated_count(&self) -> usize {
104        self.comments.iter().filter(|c| c.outdated).count()
105    }
106
107    /// Returns all comments, in insertion order.
108    #[must_use]
109    pub fn comments(&self) -> &[ReviewComment] {
110        &self.comments
111    }
112
113    /// Returns comments associated with a path.
114    pub fn comments_for_file(&self, path: &RepoPath) -> impl Iterator<Item = &ReviewComment> {
115        self.comments
116            .iter()
117            .filter(move |comment| &comment.context.path == path || &comment.anchor.path == path)
118    }
119
120    #[must_use]
121    pub fn comments_for_anchor<'a>(
122        &'a self,
123        anchor: &'a LineAnchor,
124    ) -> impl DoubleEndedIterator<Item = &'a ReviewComment> {
125        self.comments
126            .iter()
127            .filter(move |comment| &comment.anchor == anchor)
128    }
129
130    /// Removes all comments.
131    pub fn clear(&mut self) {
132        self.comments.clear();
133    }
134
135    /// Reattaches comments to a replacement document and marks misses outdated.
136    pub fn reconcile(&mut self, document: &DiffDocument) {
137        if self.comments.is_empty() {
138            return;
139        }
140        let wanted: HashSet<Fingerprint> = self
141            .comments
142            .iter()
143            .map(|comment| comment.anchor.content_fingerprint)
144            .collect();
145        let mut candidates: HashMap<Fingerprint, Vec<LineAnchor>> = HashMap::new();
146        for file in &document.files {
147            for (hunk_index, hunk) in file.hunks.iter().enumerate() {
148                for (line_index, line) in hunk.lines.iter().enumerate() {
149                    for side in line.kind.sides() {
150                        let fingerprint = LineAnchor::content_fingerprint_of(*side, line);
151                        if !wanted.contains(&fingerprint) {
152                            continue;
153                        }
154                        if let Some(anchor) =
155                            LineAnchor::for_line(file, *side, hunk_index, line_index)
156                        {
157                            candidates.entry(fingerprint).or_default().push(anchor);
158                        }
159                    }
160                }
161            }
162        }
163
164        for comment in &mut self.comments {
165            let matched = candidates
166                .get(&comment.anchor.content_fingerprint)
167                .and_then(|bucket| reattach(bucket, &comment.anchor));
168            match matched {
169                Some(anchor) => {
170                    comment.anchor = anchor;
171                    comment.outdated = false;
172                }
173                None => comment.outdated = true,
174            }
175        }
176    }
177
178    #[must_use]
179    pub fn submission(&self) -> ReviewSubmission {
180        self.submission_with(&AgentFeedbackOptions::default())
181    }
182
183    #[must_use]
184    pub fn submission_with(&self, options: &AgentFeedbackOptions) -> ReviewSubmission {
185        ReviewSubmission {
186            comments: self.comments.clone(),
187            formatted: format_review(self, options),
188        }
189    }
190}
191
192fn reattach(bucket: &[LineAnchor], previous: &LineAnchor) -> Option<LineAnchor> {
193    if let Some(exact) = bucket.iter().find(|anchor| *anchor == previous) {
194        return Some(exact.clone());
195    }
196    let line = previous.line_number().unwrap_or(0);
197    bucket
198        .iter()
199        .filter(|anchor| anchor.addresses_same_side(previous))
200        .min_by_key(|anchor| anchor.line_number().unwrap_or(0).abs_diff(line))
201        .cloned()
202}
203
204/// Structured review data and its deterministic prompt representation.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct ReviewSubmission {
207    pub comments: Vec<ReviewComment>,
208    pub formatted: String,
209}
210
211/// Options controlling feedback wording.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct AgentFeedbackOptions {
214    pub intro: String,
215    pub include_outdated: bool,
216}
217
218impl Default for AgentFeedbackOptions {
219    fn default() -> Self {
220        Self {
221            intro: "I'm reviewing the working tree diff. Here are my comments:".into(),
222            include_outdated: true,
223        }
224    }
225}
226
227#[must_use]
228pub fn format_review(review: &Review, options: &AgentFeedbackOptions) -> String {
229    let mut comments: Vec<&ReviewComment> = review
230        .comments
231        .iter()
232        .filter(|comment| options.include_outdated || !comment.outdated)
233        .collect();
234    comments.sort_by(|left, right| {
235        left.anchor
236            .path
237            .cmp(&right.anchor.path)
238            .then_with(|| {
239                left.anchor
240                    .line_number()
241                    .unwrap_or(0)
242                    .cmp(&right.anchor.line_number().unwrap_or(0))
243            })
244            .then_with(|| left.anchor.side.cmp(&right.anchor.side))
245            .then_with(|| left.id.cmp(&right.id))
246    });
247
248    let mut out = options.intro.clone();
249    let mut current: Option<&RepoPath> = None;
250    for comment in comments {
251        if current != Some(&comment.anchor.path) {
252            current = Some(&comment.anchor.path);
253            let _ = write!(out, "\n\n## `{}`", comment.anchor.path);
254        }
255        let side = match comment.anchor.side {
256            DiffSide::Old => "removed",
257            DiffSide::New => "added",
258        };
259        let line = comment
260            .anchor
261            .line_number()
262            .map_or_else(|| "unknown".to_owned(), |number| number.to_string());
263        let _ = write!(
264            out,
265            "\n\n**Line {line} ({side}):** `{}`\n> {}",
266            comment.context.line_text,
267            comment.body.replace('\n', "\n> ")
268        );
269        if comment.outdated {
270            out.push_str("\n> _(outdated)_");
271        }
272    }
273    out
274}
275
276/// A repository mutation requested by a diff review UI.
277///
278/// Renderers emit intents but never execute Git themselves. Embedding hosts
279/// execute the action and install a refreshed [`crate::DiffDocument`] snapshot.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281pub enum RepositoryAction {
282    StagePaths(Vec<RepoPath>),
283    UnstagePaths(Vec<RepoPath>),
284    StageAll,
285    UnstageAll,
286    Commit { message: String },
287    Discard { path: RepoPath, status: FileStatus },
288}
289
290/// Events emitted by a diff review UI.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub enum DiffReviewEvent {
293    RepositoryAction(RepositoryAction),
294    SetScope(DiffScope),
295    Refresh,
296    SubmitReview(ReviewSubmission),
297    CopyFormattedReview(String),
298    Cancel,
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::{DiffDocument, FileDiff};
305
306    fn anchor(file: &FileDiff, side: DiffSide, line: usize) -> LineAnchor {
307        LineAnchor::for_line(file, side, 0, line).expect("anchor")
308    }
309
310    #[test]
311    fn repository_actions_round_trip() {
312        let event = DiffReviewEvent::RepositoryAction(RepositoryAction::Discard {
313            path: crate::RepoPath::new("src/lib.rs").unwrap(),
314            status: crate::FileStatus::Modified,
315        });
316        let json = serde_json::to_string(&event).unwrap();
317        assert_eq!(
318            serde_json::from_str::<DiffReviewEvent>(&json).unwrap(),
319            event
320        );
321    }
322
323    #[test]
324    fn crud_and_round_trip() {
325        let file = FileDiff::from_texts("a.rs", "old\n", "new\n").unwrap();
326        let mut review = Review::default();
327        let id = review.add_comment(anchor(&file, DiffSide::New, 1), "fix");
328        assert!(review.edit_comment(id, "fix it"));
329        assert_eq!(review.comment(id).unwrap().body, "fix it");
330        assert_eq!(review.len(), 1);
331        assert!(serde_json::to_string(&review).unwrap().contains("fix it"));
332        assert!(review.remove_comment(id).is_some());
333        assert!(review.remove_comment(id).is_none());
334    }
335
336    #[test]
337    fn missing_is_outdated() {
338        let file = FileDiff::from_texts("a.rs", "old\n", "new\n").unwrap();
339        let mut review = Review::default();
340        review.add_comment(anchor(&file, DiffSide::New, 1), "why");
341        review.reconcile(&DiffDocument::empty());
342        assert!(review.comments()[0].outdated);
343        assert_eq!(review.outdated_count(), 1);
344    }
345
346    #[test]
347    fn moved_content_reattaches_by_nearest_content_fingerprint() {
348        let before = FileDiff::from_texts("a.rs", "a\nb\n", "a\ntarget\nb\n").unwrap();
349        let mut review = Review::default();
350        review.add_comment_with_context(anchor(&before, DiffSide::New, 1), "target", "keep this");
351
352        let after = FileDiff::from_texts("a.rs", "a\nb\n", "a\ninserted\ntarget\nb\n").unwrap();
353        review.reconcile(&DiffDocument {
354            repo_root: String::new(),
355            files: vec![after],
356        });
357
358        assert!(!review.comments()[0].outdated);
359        assert_eq!(review.comments()[0].anchor.new_line_no, Some(3));
360        assert_eq!(review.comments()[0].context.line_text, "target");
361    }
362
363    #[test]
364    fn an_unchanged_document_restores_exact_anchors() {
365        let file = FileDiff::from_texts("a.rs", "a\nb\n", "a\nc\n").unwrap();
366        let target = anchor(&file, DiffSide::New, 2);
367        let mut review = Review::default();
368        review.add_comment(target.clone(), "note");
369        review.reconcile(&DiffDocument {
370            repo_root: String::new(),
371            files: vec![file],
372        });
373        assert!(!review.comments()[0].outdated);
374        assert_eq!(review.comments()[0].anchor, target);
375    }
376
377    #[test]
378    fn formatting_is_deterministic_and_can_drop_outdated() {
379        let file = FileDiff::from_texts("a.rs", "old\n", "new\n").unwrap();
380        let mut review = Review::default();
381        review.add_comment_with_context(anchor(&file, DiffSide::New, 1), "new", "second\nline");
382        review.add_comment_with_context(anchor(&file, DiffSide::Old, 0), "old", "first");
383        let formatted = format_review(&review, &AgentFeedbackOptions::default());
384        assert!(formatted.find("(removed)").unwrap() < formatted.find("(added)").unwrap());
385        assert!(formatted.contains("> second\n> line"));
386
387        review.reconcile(&DiffDocument::empty());
388        let hidden = format_review(
389            &review,
390            &AgentFeedbackOptions {
391                include_outdated: false,
392                ..AgentFeedbackOptions::default()
393            },
394        );
395        assert!(!hidden.contains("second"));
396    }
397}