Skip to main content

wisp/git_review/
model.rs

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    /// Normalizes command output into the model consumed by both review renderers.
197    ///
198    /// Git execution deliberately happens outside this type. Keeping this operation
199    /// synchronous makes parsing deterministic and lets tests exercise it without a
200    /// repository or subprocess.
201    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    /// Builds the canonical model used for ACP tool previews and Git reviews.
269    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}