1use std::collections::HashMap;
2use std::fmt::Write;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct DiffDocument {
8 pub repo_root: PathBuf,
9 pub files: Vec<FileDiff>,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FileDiff {
14 pub old_path: Option<String>,
15 pub path: String,
16 pub status: FileStatus,
17 pub staged: StageState,
18 pub hunks: Vec<Hunk>,
19 pub binary: bool,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Hunk {
24 pub header: String,
25 pub old_start: usize,
26 pub old_count: usize,
27 pub new_start: usize,
28 pub new_count: usize,
29 pub lines: Vec<PatchLine>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PatchLine {
34 pub kind: PatchLineKind,
35 pub text: String,
36 pub old_line_no: Option<usize>,
37 pub new_line_no: Option<usize>,
38}
39
40impl PatchLine {
41 pub fn added(text: impl Into<String>, new_line_no: usize) -> Self {
42 Self { kind: PatchLineKind::Added, text: text.into(), old_line_no: None, new_line_no: Some(new_line_no) }
43 }
44
45 pub fn removed(text: impl Into<String>, old_line_no: usize) -> Self {
46 Self { kind: PatchLineKind::Removed, text: text.into(), old_line_no: Some(old_line_no), new_line_no: None }
47 }
48
49 pub fn context(text: impl Into<String>, old_line_no: usize, new_line_no: usize) -> Self {
50 Self {
51 kind: PatchLineKind::Context,
52 text: text.into(),
53 old_line_no: Some(old_line_no),
54 new_line_no: Some(new_line_no),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum DiffScope {
61 Unstaged,
62 Staged,
63 #[default]
64 Both,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FileStatus {
69 Modified,
70 Added,
71 Deleted,
72 Renamed,
73 Untracked,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum StageState {
78 Unstaged,
79 Staged,
80 PartiallyStaged,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum PatchLineKind {
85 HunkHeader,
86 Context,
87 Added,
88 Removed,
89 Meta,
90}
91
92#[derive(Debug, Error)]
93pub enum GitDiffError {
94 #[error("Not a git repository")]
95 NotARepository,
96 #[error("Git command failed: {stderr}")]
97 CommandFailed { stderr: String },
98 #[error("Failed to parse diff: {0}")]
99 ParseError(String),
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub struct PatchAnchor {
104 pub file_index: usize,
105 pub hunk: usize,
106 pub line: usize,
107}
108
109#[derive(Debug, Clone)]
110pub struct CommentContext {
111 pub file_path: String,
112 pub line_text: String,
113 pub line_number: Option<usize>,
114 pub line_kind: PatchLineKind,
115}
116
117#[derive(Debug, Clone)]
118pub struct QueuedComment {
119 pub anchor: PatchAnchor,
120 pub body: String,
121 pub context: CommentContext,
122}
123
124#[derive(Debug, Clone, Default)]
125pub struct ReviewQueue {
126 comments: Vec<QueuedComment>,
127}
128
129impl ReviewQueue {
130 pub fn is_empty(&self) -> bool {
131 self.comments.is_empty()
132 }
133
134 pub fn len(&self) -> usize {
135 self.comments.len()
136 }
137
138 pub fn clear(&mut self) {
139 self.comments.clear();
140 }
141
142 pub fn push(&mut self, comment: QueuedComment) {
143 self.comments.push(comment);
144 }
145
146 pub fn pop(&mut self) -> Option<QueuedComment> {
147 self.comments.pop()
148 }
149
150 pub fn comments_for_file(&self, file_path: &str) -> impl Iterator<Item = &QueuedComment> {
151 self.comments.iter().filter(move |c| c.context.file_path == file_path)
152 }
153
154 pub fn comments(&self) -> &[QueuedComment] {
155 &self.comments
156 }
157
158 pub fn format_prompt(&self) -> String {
159 let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
160 let mut file_order: Vec<&str> = Vec::new();
161 let mut grouped: HashMap<&str, Vec<&QueuedComment>> = HashMap::new();
162
163 for comment in &self.comments {
164 let path = comment.context.file_path.as_str();
165 if !grouped.contains_key(path) {
166 file_order.push(path);
167 }
168 grouped.entry(path).or_default().push(comment);
169 }
170
171 for file_path in file_order {
172 let file_comments = grouped.get(file_path).expect("group exists for ordered path");
173 write!(prompt, "\n## `{file_path}`\n").unwrap();
174
175 for comment in file_comments {
176 let kind_label = match comment.context.line_kind {
177 PatchLineKind::Added => "added",
178 PatchLineKind::Removed => "removed",
179 PatchLineKind::Context => "context",
180 PatchLineKind::HunkHeader => "header",
181 PatchLineKind::Meta => "meta",
182 };
183 let line_ref = match comment.context.line_number {
184 Some(n) => format!("Line {n} ({kind_label})"),
185 None => kind_label.to_string(),
186 };
187 write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.context.line_text, comment.body).unwrap();
188 }
189 }
190
191 prompt
192 }
193}
194
195impl DiffDocument {
196 pub fn from_git_output(
202 repo_root: PathBuf,
203 diff_output: &str,
204 status_output: &str,
205 untracked_files: impl IntoIterator<Item = (String, Vec<u8>)>,
206 scope: DiffScope,
207 ) -> Result<Self, GitDiffError> {
208 let mut files = if diff_output.trim().is_empty() { Vec::new() } else { parse_unified_diff(diff_output)? };
209
210 if scope.includes_untracked() {
211 files.extend(untracked_files.into_iter().map(|(path, bytes)| build_untracked_file_diff(path, &bytes)));
212 }
213
214 let status_map = parse_porcelain_status(status_output);
215 for file in &mut files {
216 file.staged = status_map.get(&file.path).copied().unwrap_or(StageState::Unstaged);
217 }
218 files.sort_by(|left, right| left.path.cmp(&right.path));
219 Ok(Self { repo_root, files })
220 }
221}
222
223impl DiffScope {
224 pub fn label(self) -> &'static str {
225 match self {
226 Self::Unstaged => "Unstaged",
227 Self::Staged => "Staged",
228 Self::Both => "Both",
229 }
230 }
231
232 pub fn next(self) -> Self {
233 match self {
234 Self::Both => Self::Unstaged,
235 Self::Unstaged => Self::Staged,
236 Self::Staged => Self::Both,
237 }
238 }
239
240 pub fn includes_untracked(self) -> bool {
241 !matches!(self, Self::Staged)
242 }
243}
244
245impl FileStatus {
246 pub fn marker(self) -> char {
247 match self {
248 Self::Modified => 'M',
249 Self::Added => 'A',
250 Self::Deleted => 'D',
251 Self::Renamed => 'R',
252 Self::Untracked => '?',
253 }
254 }
255
256 pub fn label(self) -> &'static str {
257 match self {
258 Self::Modified => "modified",
259 Self::Added => "new file",
260 Self::Deleted => "deleted",
261 Self::Renamed => "renamed",
262 Self::Untracked => "untracked",
263 }
264 }
265}
266
267impl FileDiff {
268 pub fn from_texts(path: impl Into<String>, old: &str, new: &str) -> Self {
270 let path = path.into();
271 let status = match (old.is_empty(), new.is_empty()) {
272 (true, false) => FileStatus::Added,
273 (false, true) => FileStatus::Deleted,
274 _ => FileStatus::Modified,
275 };
276 let old_lines: Vec<&str> = old.lines().collect();
277 let new_lines: Vec<&str> = new.lines().collect();
278 let mut patch_lines = Vec::new();
279 let mut old_number = 0;
280 let mut new_number = 0;
281
282 for operation in similar::TextDiff::from_lines(old, new).ops() {
283 match *operation {
284 similar::DiffOp::Equal { old_index, new_index: _, len } => {
285 for offset in 0..len {
286 old_number += 1;
287 new_number += 1;
288 patch_lines.push(PatchLine::context(
289 source_line(&old_lines, old_index + offset),
290 old_number,
291 new_number,
292 ));
293 }
294 }
295 similar::DiffOp::Delete { old_index, old_len, .. } => {
296 for offset in 0..old_len {
297 old_number += 1;
298 patch_lines.push(PatchLine::removed(source_line(&old_lines, old_index + offset), old_number));
299 }
300 }
301 similar::DiffOp::Insert { new_index, new_len, .. } => {
302 for offset in 0..new_len {
303 new_number += 1;
304 patch_lines.push(PatchLine::added(source_line(&new_lines, new_index + offset), new_number));
305 }
306 }
307 similar::DiffOp::Replace { old_index, old_len, new_index, new_len } => {
308 for offset in 0..old_len {
309 old_number += 1;
310 patch_lines.push(PatchLine::removed(source_line(&old_lines, old_index + offset), old_number));
311 }
312 for offset in 0..new_len {
313 new_number += 1;
314 patch_lines.push(PatchLine::added(source_line(&new_lines, new_index + offset), new_number));
315 }
316 }
317 }
318 }
319
320 trim_patch_context(&mut patch_lines);
321 let hunks = if patch_lines.is_empty() {
322 Vec::new()
323 } else {
324 let old_start = patch_lines.iter().find_map(|line| line.old_line_no).unwrap_or(0);
325 let new_start = patch_lines.iter().find_map(|line| line.new_line_no).unwrap_or(0);
326 let old_count = patch_lines.iter().filter(|line| line.old_line_no.is_some()).count();
327 let new_count = patch_lines.iter().filter(|line| line.new_line_no.is_some()).count();
328 let header = format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@");
329 let mut lines = Vec::with_capacity(patch_lines.len() + 1);
330 lines.push(PatchLine {
331 kind: PatchLineKind::HunkHeader,
332 text: header.clone(),
333 old_line_no: None,
334 new_line_no: None,
335 });
336 lines.extend(patch_lines);
337 vec![Hunk { header, old_start, old_count, new_start, new_count, lines }]
338 };
339
340 Self {
341 old_path: (status != FileStatus::Added).then(|| path.clone()),
342 path,
343 status,
344 staged: StageState::Unstaged,
345 hunks,
346 binary: false,
347 }
348 }
349
350 pub fn additions(&self) -> usize {
351 self.hunks.iter().map(Hunk::additions).sum()
352 }
353
354 pub fn deletions(&self) -> usize {
355 self.hunks.iter().map(Hunk::deletions).sum()
356 }
357
358 pub fn language(&self) -> &str {
359 Path::new(&self.path).extension().and_then(|extension| extension.to_str()).unwrap_or_default()
360 }
361}
362
363impl Hunk {
364 pub fn additions(&self) -> usize {
365 self.lines.iter().filter(|line| line.kind == PatchLineKind::Added).count()
366 }
367
368 pub fn deletions(&self) -> usize {
369 self.lines.iter().filter(|line| line.kind == PatchLineKind::Removed).count()
370 }
371}
372
373pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
374
375pub fn parse_porcelain_status(input: &str) -> HashMap<String, StageState> {
376 let mut map = HashMap::new();
377 let mut tokens = input.split('\0').filter(|token| !token.is_empty());
378
379 while let Some(record) = tokens.next() {
380 if record.len() < 3 {
381 continue;
382 }
383 let bytes = record.as_bytes();
384 let index = bytes[0] as char;
385 let worktree = bytes[1] as char;
386 let path = if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
387 tokens.next().unwrap_or(&record[3..])
388 } else {
389 &record[3..]
390 };
391
392 let state = match (index, worktree) {
393 ('?', '?') | (' ', _) => StageState::Unstaged,
394 (_, ' ') => StageState::Staged,
395 _ => StageState::PartiallyStaged,
396 };
397 map.insert(path.to_string(), state);
398 }
399
400 map
401}
402
403pub(crate) fn build_untracked_file_diff(path: String, bytes: &[u8]) -> FileDiff {
404 if bytes.iter().take(8192).any(|byte| *byte == 0) {
405 return binary_untracked(path);
406 }
407 let Ok(content) = std::str::from_utf8(bytes) else {
408 return binary_untracked(path);
409 };
410 let source_lines: Vec<&str> = content.lines().collect();
411 let line_count = source_lines.len();
412 let header = format!("@@ -0,0 +1,{line_count} @@");
413 let mut lines =
414 vec![PatchLine { kind: PatchLineKind::HunkHeader, text: header.clone(), old_line_no: None, new_line_no: None }];
415 lines.extend(source_lines.iter().enumerate().map(|(index, text)| PatchLine {
416 kind: PatchLineKind::Added,
417 text: (*text).to_string(),
418 old_line_no: None,
419 new_line_no: Some(index + 1),
420 }));
421
422 FileDiff {
423 old_path: None,
424 path,
425 status: FileStatus::Untracked,
426 staged: StageState::Unstaged,
427 hunks: vec![Hunk { header, old_start: 0, old_count: 0, new_start: 1, new_count: line_count, lines }],
428 binary: false,
429 }
430}
431
432pub(crate) fn binary_untracked(path: String) -> FileDiff {
433 FileDiff {
434 old_path: None,
435 path,
436 status: FileStatus::Untracked,
437 staged: StageState::Unstaged,
438 hunks: Vec::new(),
439 binary: true,
440 }
441}
442
443pub fn parse_unified_diff(input: &str) -> Result<Vec<FileDiff>, GitDiffError> {
444 split_diff_files(input).into_iter().map(parse_file_diff).collect()
445}
446
447fn split_diff_files(input: &str) -> Vec<&str> {
448 let mut chunks = Vec::new();
449 let mut start = None;
450 let mut line_start = 0;
451
452 while line_start < input.len() {
453 let line_end = input[line_start..].find('\n').map_or(input.len(), |index| line_start + index + 1);
454 let line = &input[line_start..line_end];
455 if line.starts_with("diff --git ") {
456 if let Some(chunk_start) = start {
457 chunks.push(&input[chunk_start..line_start]);
458 }
459 start = Some(line_start);
460 }
461 line_start = line_end;
462 }
463 if let Some(chunk_start) = start {
464 chunks.push(&input[chunk_start..]);
465 }
466 chunks
467}
468
469fn parse_file_diff(chunk: &str) -> Result<FileDiff, GitDiffError> {
470 let lines: Vec<&str> = chunk.lines().collect();
471 let Some(header) = lines.first() else {
472 return Err(GitDiffError::ParseError("Empty diff chunk".to_string()));
473 };
474 let (old_path, new_path) = parse_diff_header(header)?;
475 let (status, binary, rename_from, hunk_start) = scan_file_metadata(&lines);
476 let hunks = if binary { Vec::new() } else { parse_file_hunks(&lines[hunk_start..])? };
477
478 Ok(FileDiff {
479 old_path: resolve_old_path(status, rename_from, old_path),
480 path: new_path,
481 status,
482 staged: StageState::Unstaged,
483 hunks,
484 binary,
485 })
486}
487
488fn scan_file_metadata(lines: &[&str]) -> (FileStatus, bool, Option<String>, usize) {
489 let mut status = FileStatus::Modified;
490 let mut binary = false;
491 let mut rename_from = None;
492 let mut index = 1;
493 while index < lines.len() {
494 let line = lines[index];
495 if line.starts_with("new file mode") {
496 status = FileStatus::Added;
497 } else if line.starts_with("deleted file mode") {
498 status = FileStatus::Deleted;
499 } else if let Some(path) = line.strip_prefix("rename from ") {
500 status = FileStatus::Renamed;
501 rename_from = Some(path.to_string());
502 } else if line.starts_with("rename to ") {
503 status = FileStatus::Renamed;
504 } else if line.starts_with("Binary files ") {
505 binary = true;
506 } else if line.starts_with("@@") {
507 break;
508 }
509 index += 1;
510 }
511 (status, binary, rename_from, index)
512}
513
514fn parse_file_hunks(lines: &[&str]) -> Result<Vec<Hunk>, GitDiffError> {
515 let mut hunks = Vec::new();
516 let mut index = 0;
517 while index < lines.len() {
518 if lines[index].starts_with("@@") {
519 let (hunk, consumed) = parse_hunk(&lines[index..])?;
520 hunks.push(hunk);
521 index += consumed;
522 } else {
523 index += 1;
524 }
525 }
526 Ok(hunks)
527}
528
529fn resolve_old_path(status: FileStatus, rename_from: Option<String>, old_path: String) -> Option<String> {
530 match status {
531 FileStatus::Added | FileStatus::Untracked => None,
532 FileStatus::Renamed => rename_from.or(Some(old_path)),
533 _ => Some(old_path),
534 }
535}
536
537fn parse_diff_header(line: &str) -> Result<(String, String), GitDiffError> {
538 let rest = line
539 .strip_prefix("diff --git ")
540 .ok_or_else(|| GitDiffError::ParseError(format!("Invalid diff header: {line}")))?;
541 if let Some((old, new)) = rest.split_once(" b/") {
542 Ok((old.strip_prefix("a/").unwrap_or(old).to_string(), new.to_string()))
543 } else {
544 Err(GitDiffError::ParseError(format!("Cannot parse paths from: {line}")))
545 }
546}
547
548fn source_line<'a>(lines: &[&'a str], index: usize) -> &'a str {
549 lines.get(index).copied().unwrap_or("")
550}
551
552fn trim_patch_context(lines: &mut Vec<PatchLine>) {
553 const CONTEXT: usize = 3;
554 let Some(first_change) = lines.iter().position(|line| line.kind != PatchLineKind::Context) else {
555 lines.clear();
556 return;
557 };
558 let last_change = lines.iter().rposition(|line| line.kind != PatchLineKind::Context).unwrap_or(first_change);
559 let start = first_change.saturating_sub(CONTEXT);
560 let end = (last_change + CONTEXT + 1).min(lines.len());
561 lines.drain(end..);
562 lines.drain(..start);
563}
564
565fn parse_hunk(lines: &[&str]) -> Result<(Hunk, usize), GitDiffError> {
566 let header = lines[0];
567 let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
568 let mut patch_lines = vec![PatchLine {
569 kind: PatchLineKind::HunkHeader,
570 text: header.to_string(),
571 old_line_no: None,
572 new_line_no: None,
573 }];
574 let mut old_line = old_start;
575 let mut new_line = new_start;
576 let mut index = 1;
577
578 while index < lines.len() && !lines[index].starts_with("@@") {
579 let line = lines[index];
580 let patch_line = if let Some(text) = line.strip_prefix('+') {
581 let result = PatchLine::added(text, new_line);
582 new_line += 1;
583 result
584 } else if let Some(text) = line.strip_prefix('-') {
585 let result = PatchLine::removed(text, old_line);
586 old_line += 1;
587 result
588 } else if let Some(text) = line.strip_prefix(' ') {
589 let result = PatchLine::context(text, old_line, new_line);
590 old_line += 1;
591 new_line += 1;
592 result
593 } else if line.starts_with('\\') {
594 PatchLine { kind: PatchLineKind::Meta, text: line.to_string(), old_line_no: None, new_line_no: None }
595 } else {
596 let result = PatchLine::context(line, old_line, new_line);
597 old_line += 1;
598 new_line += 1;
599 result
600 };
601 patch_lines.push(patch_line);
602 index += 1;
603 }
604
605 Ok((Hunk { header: header.to_string(), old_start, old_count, new_start, new_count, lines: patch_lines }, index))
606}
607
608fn parse_hunk_header(header: &str) -> Result<(usize, usize, usize, usize), GitDiffError> {
609 let invalid = || GitDiffError::ParseError(format!("Invalid hunk header: {header}"));
610 let rest = header.strip_prefix("@@ -").ok_or_else(invalid)?;
611 let end = rest.find(" @@").ok_or_else(invalid)?;
612 let (old_range, new_range) = rest[..end].split_once(" +").ok_or_else(invalid)?;
613 let (old_start, old_count) = parse_range(old_range).ok_or_else(invalid)?;
614 let (new_start, new_count) = parse_range(new_range).ok_or_else(invalid)?;
615 Ok((old_start, old_count, new_start, new_count))
616}
617
618fn parse_range(value: &str) -> Option<(usize, usize)> {
619 if let Some((start, count)) = value.split_once(',') {
620 Some((start.parse().ok()?, count.parse().ok()?))
621 } else {
622 Some((value.parse().ok()?, 1))
623 }
624}