forge_core_utils/
diff.rs

1use std::borrow::Cow;
2
3use serde::{Deserialize, Serialize};
4use similar::{ChangeTag, TextDiff};
5use ts_rs_forge::TS;
6
7// Structs compatable with props: https://github.com/MrWangJustToDo/git-diff-view
8
9#[derive(Debug, Clone, Serialize, Deserialize, TS)]
10#[serde(rename_all = "camelCase")]
11pub struct FileDiffDetails {
12    pub file_name: Option<String>,
13    pub content: Option<String>,
14}
15
16// Worktree diffs for the diffs tab: minimal, no hunks, optional full contents
17#[derive(Debug, Clone, Serialize, Deserialize, TS)]
18#[serde(rename_all = "camelCase")]
19pub struct Diff {
20    pub change: DiffChangeKind,
21    pub old_path: Option<String>,
22    pub new_path: Option<String>,
23    pub old_content: Option<String>,
24    pub new_content: Option<String>,
25    /// True when file contents are intentionally omitted (e.g., too large)
26    pub content_omitted: bool,
27    /// Optional precomputed stats for omitted content
28    pub additions: Option<usize>,
29    pub deletions: Option<usize>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, TS)]
33#[ts(export)]
34#[serde(rename_all = "camelCase")]
35pub enum DiffChangeKind {
36    Added,
37    Deleted,
38    Modified,
39    Renamed,
40    Copied,
41    PermissionChange,
42}
43
44// ==============================
45// Unified diff utility functions
46// ==============================
47
48/// Converts a replace diff to a unified diff hunk without the hunk header.
49/// The hunk returned will have valid hunk, and diff lines.
50pub fn create_unified_diff_hunk(old: &str, new: &str) -> String {
51    // normalize ending line feed to optimize diff output
52    let mut old = old.to_string();
53    let mut new = new.to_string();
54    if !old.ends_with('\n') {
55        old.push('\n');
56    }
57    if !new.ends_with('\n') {
58        new.push('\n');
59    }
60
61    let diff = TextDiff::from_lines(&old, &new);
62
63    let mut out = String::new();
64
65    // We need a valud hunk header. assume lines are 0. but - + count will be correct.
66
67    let old_count = diff.old_slices().len();
68    let new_count = diff.new_slices().len();
69
70    out.push_str(&format!("@@ -1,{old_count} +1,{new_count} @@\n"));
71
72    for change in diff.iter_all_changes() {
73        let sign = match change.tag() {
74            ChangeTag::Equal => ' ',
75            ChangeTag::Delete => '-',
76            ChangeTag::Insert => '+',
77        };
78        let val = change.value();
79        out.push(sign);
80        out.push_str(val);
81    }
82
83    out
84}
85
86/// Creates a full unified diff with the file path in the header.
87pub fn create_unified_diff(file_path: &str, old: &str, new: &str) -> String {
88    let mut out = String::new();
89    out.push_str(format!("--- a/{file_path}\n+++ b/{file_path}\n").as_str());
90    out.push_str(&create_unified_diff_hunk(old, new));
91    out
92}
93
94/// Compute addition/deletion counts between two text snapshots.
95pub fn compute_line_change_counts(old: &str, new: &str) -> (usize, usize) {
96    let old = ensure_newline(old);
97    let new = ensure_newline(new);
98
99    let diff = TextDiff::from_lines(&old, &new);
100
101    let mut additions = 0usize;
102    let mut deletions = 0usize;
103    for change in diff.iter_all_changes() {
104        match change.tag() {
105            ChangeTag::Insert => additions += 1,
106            ChangeTag::Delete => deletions += 1,
107            ChangeTag::Equal => {}
108        }
109    }
110
111    (additions, deletions)
112}
113
114// ensure a line ends with a newline character
115fn ensure_newline(line: &str) -> Cow<'_, str> {
116    if line.ends_with('\n') {
117        Cow::Borrowed(line)
118    } else {
119        let mut owned = line.to_owned();
120        owned.push('\n');
121        Cow::Owned(owned)
122    }
123}
124
125/// Extracts unified diff hunks from a string containing a full unified diff.
126/// Tolerates non-diff lines and missing `@@`` hunk headers.
127pub fn extract_unified_diff_hunks(unified_diff: &str) -> Vec<String> {
128    let lines = unified_diff.split_inclusive('\n').collect::<Vec<_>>();
129
130    if !lines.iter().any(|l| l.starts_with("@@")) {
131        // No @@ hunk headers: treat as a single hunk
132        let hunk = lines
133            .iter()
134            .copied()
135            .filter(|line| line.starts_with([' ', '+', '-']))
136            .collect::<String>();
137
138        let old_count = lines
139            .iter()
140            .filter(|line| line.starts_with(['-', ' ']))
141            .count();
142        let new_count = lines
143            .iter()
144            .filter(|line| line.starts_with(['+', ' ']))
145            .count();
146
147        return if hunk.is_empty() {
148            vec![]
149        } else {
150            vec![format!("@@ -1,{old_count} +1,{new_count} @@\n{hunk}")]
151        };
152    }
153
154    let mut hunks = vec![];
155    let mut current_hunk: Option<String> = None;
156
157    // Collect hunks starting with @@ headers
158    for line in lines {
159        if line.starts_with("@@") {
160            // new hunk starts
161            if let Some(hunk) = current_hunk.take() {
162                // flush current hunk
163                if !hunk.is_empty() {
164                    hunks.push(hunk);
165                }
166            }
167            current_hunk = Some(line.to_string());
168        } else if let Some(ref mut hunk) = current_hunk {
169            if line.starts_with([' ', '+', '-']) {
170                // hunk content
171                hunk.push_str(line);
172            } else {
173                // unkown line, flush current hunk
174                if !hunk.is_empty() {
175                    hunks.push(hunk.clone());
176                }
177                current_hunk = None;
178            }
179        }
180    }
181    // we have reached the end. flush the last hunk if it exists
182    if let Some(hunk) = current_hunk
183        && !hunk.is_empty()
184    {
185        hunks.push(hunk);
186    }
187
188    // Fix hunk headers if they are empty @@\n
189    hunks = fix_hunk_headers(hunks);
190
191    hunks
192}
193
194// Helper function to ensure valid hunk headers
195fn fix_hunk_headers(hunks: Vec<String>) -> Vec<String> {
196    if hunks.is_empty() {
197        return hunks;
198    }
199
200    let mut new_hunks = Vec::new();
201    // if hunk header is empty @@\n, ten we need to replace it with a valid header
202    for hunk in hunks {
203        let mut lines = hunk
204            .split_inclusive('\n')
205            .map(str::to_string)
206            .collect::<Vec<_>>();
207        if lines.len() < 2 {
208            // empty hunk, skip
209            continue;
210        }
211
212        let header = &lines[0];
213        if !header.starts_with("@@") {
214            // no header, skip
215            continue;
216        }
217
218        if header.trim() == "@@" {
219            // empty header, replace with a valid one
220            lines.remove(0);
221            let old_count = lines
222                .iter()
223                .filter(|line| line.starts_with(['-', ' ']))
224                .count();
225            let new_count = lines
226                .iter()
227                .filter(|line| line.starts_with(['+', ' ']))
228                .count();
229            let new_header = format!("@@ -1,{old_count} +1,{new_count} @@");
230            lines.insert(0, new_header);
231            new_hunks.push(lines.join(""));
232        } else {
233            // valid header, keep as is
234            new_hunks.push(hunk);
235        }
236    }
237
238    new_hunks
239}
240
241/// Creates a full unified diff with the file path in the header,
242pub fn concatenate_diff_hunks(file_path: &str, hunks: &[String]) -> String {
243    let mut unified_diff = String::new();
244
245    let header = format!("--- a/{file_path}\n+++ b/{file_path}\n");
246
247    unified_diff.push_str(&header);
248
249    if !hunks.is_empty() {
250        let lines = hunks
251            .iter()
252            .flat_map(|hunk| hunk.lines())
253            .filter(|line| line.starts_with("@@ ") || line.starts_with([' ', '+', '-']))
254            .collect::<Vec<_>>();
255        unified_diff.push_str(lines.join("\n").as_str());
256        if !unified_diff.ends_with('\n') {
257            unified_diff.push('\n');
258        }
259    }
260
261    unified_diff
262}