1use 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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Review {
35 comments: Vec<ReviewComment>,
36 next_id: u64,
37}
38
39impl Review {
40 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 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 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 #[must_use]
92 pub fn is_empty(&self) -> bool {
93 self.comments.is_empty()
94 }
95
96 #[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 #[must_use]
109 pub fn comments(&self) -> &[ReviewComment] {
110 &self.comments
111 }
112
113 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 pub fn clear(&mut self) {
132 self.comments.clear();
133 }
134
135 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct ReviewSubmission {
207 pub comments: Vec<ReviewComment>,
208 pub formatted: String,
209}
210
211#[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#[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#[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}