radicle_cli/git/
ddiff.rs

1//! `DDiff` is a diff between diffs.  The type aids in the review of a `Patch` to a project by
2//! providing useful context between `Patch` updates a regular `Diff` will miss.
3//!
4//! For example, lets start with a file containing a list of words.
5//!
6//! ```text
7//! componentwise
8//! reusing
9//! simplest
10//! crag
11//! offended
12//! omitting
13//! ```
14//! Where a change is proposed to the file replacing a set of lines.  The example includes the
15//! `HunkHeader` "@ .. @" for completeness, but it can be mostly ignored.
16//!
17//! ```text
18//! @@ -0,6 +0,6 @@
19//! componentwise
20//! reusing
21//! -simplest
22//! -crag
23//! -offended
24//! +interpreters
25//! +soiled
26//! +snuffing
27//! omitting
28//! ```
29//!
30//! The author updates the `Patch` to keep 'offended' and remove 'interpreters'.
31//!
32//! ```text
33//! @@ -0,6 +0,6 @@
34//! componentwise
35//! reusing
36//! -simplest
37//! -crag
38//!  offended
39//! -interpreters
40//! +soiled
41//! +snuffing
42//! omitting
43//! ```
44//! The `DDiff` will show the what changes are being made, overlayed on to the original diff and
45//! the diff's original file as context.
46//!
47//! ```text
48//! @@ -0,9 +0,8 @@
49//!   componentwise
50//!   reusing
51//!  -simplest
52//!  -crag
53//! --offended
54//! + offended
55//! -+interpreters
56//!  +soiled
57//!  +snuffing
58//!   omitting
59//! ```
60//!
61//! An alternative is to review a `Diff` between the resulting files after the first and second
62//! Patch versions were applied.  The first `Patch` changes and original file contents are one
63//! making it unclear what are changes to the `Patch` or changes to the original file.
64//!
65//! ```text
66//! @@ -0,9 +0,8 @@
67//!  componentwise
68//!  reusing
69//! +offended
70//! -interpreters
71//!  soiled
72//!  snuffing
73//!  omitting
74//! ```
75use radicle_surf::diff::*;
76
77use std::io;
78
79use crate::git::unified_diff;
80use crate::git::unified_diff::{Encode, Writer};
81use crate::terminal as term;
82
83/// Either the modification of a single diff [`Line`], or just contextual
84/// information.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub enum DiffModification {
87    /// An addition line is to be added.
88    AdditionAddition { line: Line, line_no: u32 },
89    AdditionContext {
90        line: Line,
91        line_no_old: u32,
92        line_no_new: u32,
93    },
94    /// An addition line is to be removed.
95    AdditionDeletion { line: Line, line_no: u32 },
96    /// A context line is to be added.
97    ContextAddition { line: Line, line_no: u32 },
98    /// A contextual line in a file, i.e. there were no changes to the line.
99    ContextContext {
100        line: Line,
101        line_no_old: u32,
102        line_no_new: u32,
103    },
104    /// A context line is to be removed.
105    ContextDeletion { line: Line, line_no: u32 },
106    /// A deletion line is to be added.
107    DeletionAddition { line: Line, line_no: u32 },
108    /// A deletion line in a diff, i.e. there were no changes to the line.
109    DeletionContext {
110        line: Line,
111        line_no_old: u32,
112        line_no_new: u32,
113    },
114    /// A deletion line is to be removed.
115    DeletionDeletion { line: Line, line_no: u32 },
116}
117
118impl unified_diff::Decode for Hunk<DiffModification> {
119    fn decode(r: &mut impl io::BufRead) -> Result<Self, unified_diff::Error> {
120        let header = unified_diff::HunkHeader::decode(r)?;
121
122        let mut lines = Vec::new();
123        let mut new_line: u32 = 0;
124        let mut old_line: u32 = 0;
125
126        while old_line < header.old_size || new_line < header.new_size {
127            if old_line > header.old_size {
128                return Err(unified_diff::Error::syntax(format!(
129                    "expected '{0}' old lines",
130                    header.old_size,
131                )));
132            } else if new_line > header.new_size {
133                return Err(unified_diff::Error::syntax(format!(
134                    "expected '{0}' new lines",
135                    header.new_size,
136                )));
137            }
138
139            let mut line = DiffModification::decode(r).map_err(|e| {
140                if e.is_eof() {
141                    unified_diff::Error::syntax(format!(
142                        "expected '{}' old lines and '{}' new lines, but found '{}' and '{}'",
143                        header.old_size, header.new_size, old_line, new_line,
144                    ))
145                } else {
146                    e
147                }
148            })?;
149
150            match &mut line {
151                DiffModification::AdditionAddition { line_no, .. } => {
152                    *line_no = new_line;
153                    new_line += 1;
154                }
155                DiffModification::AdditionContext {
156                    line_no_old,
157                    line_no_new,
158                    ..
159                } => {
160                    *line_no_old = old_line;
161                    *line_no_new = new_line;
162                    old_line += 1;
163                    new_line += 1;
164                }
165                DiffModification::AdditionDeletion { line_no, .. } => {
166                    *line_no = old_line;
167                    old_line += 1;
168                }
169                DiffModification::ContextAddition { line_no, .. } => {
170                    *line_no = new_line;
171                    new_line += 1;
172                }
173                DiffModification::ContextContext {
174                    line_no_old,
175                    line_no_new,
176                    ..
177                } => {
178                    *line_no_old = old_line;
179                    *line_no_new = new_line;
180                    old_line += 1;
181                    new_line += 1;
182                }
183                DiffModification::ContextDeletion { line_no, .. } => {
184                    *line_no = old_line;
185                    old_line += 1;
186                }
187                DiffModification::DeletionAddition { line_no, .. } => {
188                    *line_no = new_line;
189                    new_line += 1;
190                }
191                DiffModification::DeletionContext {
192                    line_no_old,
193                    line_no_new,
194                    ..
195                } => {
196                    *line_no_old = old_line;
197                    *line_no_new = new_line;
198                    old_line += 1;
199                    new_line += 1;
200                }
201                DiffModification::DeletionDeletion { line_no, .. } => {
202                    *line_no = old_line;
203                    old_line += 1;
204                }
205            };
206
207            lines.push(line);
208        }
209
210        Ok(Hunk {
211            header: Line::from(header.to_unified_string()?),
212            lines,
213            old: header.old_line_range(),
214            new: header.new_line_range(),
215        })
216    }
217}
218
219impl unified_diff::Encode for Hunk<DiffModification> {
220    fn encode(&self, w: &mut Writer) -> Result<(), unified_diff::Error> {
221        // TODO: Remove trailing newlines accurately.
222        // trim_end() will destroy diff information if the diff has a trailing whitespace on
223        // purpose.
224        w.magenta(self.header.from_utf8_lossy().trim_end())?;
225        for l in &self.lines {
226            l.encode(w)?;
227        }
228        Ok(())
229    }
230}
231
232/// The DDiff version of `FileDiff`.
233#[derive(Clone, Debug, PartialEq)]
234pub struct FileDDiff {
235    pub path: std::path::PathBuf,
236    pub old: DiffFile,
237    pub new: DiffFile,
238    pub hunks: Hunks<DiffModification>,
239    pub eof: EofNewLine,
240}
241
242impl From<&FileDDiff> for unified_diff::FileHeader {
243    fn from(value: &FileDDiff) -> Self {
244        unified_diff::FileHeader::Modified {
245            path: value.path.clone(),
246            old: value.old.clone(),
247            new: value.new.clone(),
248            binary: false,
249        }
250    }
251}
252
253impl unified_diff::Decode for DiffModification {
254    fn decode(r: &mut impl std::io::BufRead) -> Result<Self, unified_diff::Error> {
255        let mut line = String::new();
256        if r.read_line(&mut line)? == 0 {
257            return Err(unified_diff::Error::UnexpectedEof);
258        }
259
260        let mut chars = line.chars();
261
262        let first = chars.next().ok_or(unified_diff::Error::UnexpectedEof)?;
263        let second = chars.next().ok_or(unified_diff::Error::UnexpectedEof)?;
264
265        let line = match (first, second) {
266            ('+', '+') => DiffModification::AdditionAddition {
267                line: chars.as_str().to_string().into(),
268                line_no: 0,
269            },
270            ('+', '-') => DiffModification::DeletionAddition {
271                line: chars.as_str().to_string().into(),
272                line_no: 0,
273            },
274            ('+', ' ') => DiffModification::ContextAddition {
275                line: chars.as_str().to_string().into(),
276                line_no: 0,
277            },
278            ('-', '+') => DiffModification::AdditionDeletion {
279                line: chars.as_str().to_string().into(),
280                line_no: 0,
281            },
282            ('-', '-') => DiffModification::DeletionDeletion {
283                line: chars.as_str().to_string().into(),
284                line_no: 0,
285            },
286            ('-', ' ') => DiffModification::ContextDeletion {
287                line: chars.as_str().to_string().into(),
288                line_no: 0,
289            },
290            (' ', '+') => DiffModification::AdditionContext {
291                line: chars.as_str().to_string().into(),
292                line_no_old: 0,
293                line_no_new: 0,
294            },
295            (' ', '-') => DiffModification::DeletionContext {
296                line: chars.as_str().to_string().into(),
297                line_no_old: 0,
298                line_no_new: 0,
299            },
300            (' ', ' ') => DiffModification::ContextContext {
301                line: chars.as_str().to_string().into(),
302                line_no_old: 0,
303                line_no_new: 0,
304            },
305            (v1, v2) => {
306                return Err(unified_diff::Error::syntax(format!(
307                    "indicator character expected, but got '{v1}{v2}'"
308                )))
309            }
310        };
311
312        Ok(line)
313    }
314}
315
316impl unified_diff::Encode for DiffModification {
317    fn encode(&self, w: &mut unified_diff::Writer) -> Result<(), unified_diff::Error> {
318        match self {
319            DiffModification::AdditionAddition { line, .. } => {
320                let s = format!("++{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
321                w.write(s, term::Style::new(term::Color::Green))?;
322            }
323            DiffModification::AdditionDeletion { line, .. } => {
324                let s = format!("-+{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
325                w.write(s, term::Style::new(term::Color::Red))?;
326            }
327            DiffModification::ContextAddition { line, .. } => {
328                let s = format!("+ {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
329                w.write(s, term::Style::new(term::Color::Green))?;
330            }
331            DiffModification::DeletionAddition { line, .. } => {
332                let s = format!("+-{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
333                w.write(s, term::Style::new(term::Color::Green))?;
334            }
335            DiffModification::DeletionDeletion { line, .. } => {
336                let s = format!("--{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
337                w.write(s, term::Style::new(term::Color::Red))?;
338            }
339            DiffModification::ContextDeletion { line, .. } => {
340                let s = format!("- {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
341                w.write(s, term::Style::new(term::Color::Red))?;
342            }
343            DiffModification::AdditionContext { line, .. } => {
344                let s = format!(" +{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
345                w.write(s, term::Style::new(term::Color::Green).dim())?
346            }
347            DiffModification::DeletionContext { line, .. } => {
348                let s = format!(" -{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
349                w.write(s, term::Style::new(term::Color::Red).dim())?;
350            }
351            DiffModification::ContextContext { line, .. } => {
352                let s = format!("  {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
353                w.write(s, term::Style::default().dim())?;
354            }
355        }
356
357        Ok(())
358    }
359}
360
361impl unified_diff::Encode for FileDDiff {
362    fn encode(&self, w: &mut unified_diff::Writer) -> Result<(), unified_diff::Error> {
363        w.encode(&unified_diff::FileHeader::from(self))?;
364        for h in self.hunks.iter() {
365            h.encode(w)?;
366        }
367
368        Ok(())
369    }
370}
371
372/// A diff of a diff.
373#[derive(Clone, Debug, PartialEq, Default)]
374pub struct DDiff {
375    files: Vec<FileDDiff>,
376}
377
378impl DDiff {
379    /// Returns an iterator of the file in the diff.
380    pub fn files(&self) -> impl Iterator<Item = &FileDDiff> {
381        self.files.iter()
382    }
383
384    /// Returns owned files in the diff.
385    pub fn into_files(self) -> Vec<FileDDiff> {
386        self.files
387    }
388}
389
390impl unified_diff::Encode for DDiff {
391    fn encode(&self, w: &mut unified_diff::Writer) -> Result<(), unified_diff::Error> {
392        for v in self.files() {
393            v.encode(w)?;
394        }
395        Ok(())
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    use crate::git::unified_diff::{Decode, Encode};
404
405    #[test]
406    fn diff_encode_decode_ddiff_hunk() {
407        let ddiff = Hunk::<DiffModification>::parse(include_str!(concat!(
408            env!("CARGO_MANIFEST_DIR"),
409            "/tests/data/ddiff_hunk.diff"
410        )))
411        .unwrap();
412        assert_eq!(
413            include_str!(concat!(
414                env!("CARGO_MANIFEST_DIR"),
415                "/tests/data/ddiff_hunk.diff"
416            )),
417            ddiff.to_unified_string().unwrap()
418        );
419    }
420}