Skip to main content

radicle_surf/
diff.rs

1//! Types that represent diff(s) in a Git repo.
2
3use std::{
4    borrow::Cow,
5    ops::Range,
6    path::{Path, PathBuf},
7    string::FromUtf8Error,
8};
9
10#[cfg(feature = "serde")]
11use serde::{Serialize, Serializer, ser, ser::SerializeStruct};
12
13use radicle_oid::Oid;
14
15pub mod git;
16
17/// The serializable representation of a `git diff`.
18///
19/// A [`Diff`] can be retrieved by the following functions:
20///    * [`crate::Repository::diff`]
21///    * [`crate::Repository::diff_commit`]
22#[cfg_attr(feature = "serde", derive(Serialize))]
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct Diff {
25    files: Vec<FileDiff>,
26    stats: Stats,
27}
28
29impl Diff {
30    /// Creates an empty diff.
31    pub(crate) fn new() -> Self {
32        Diff::default()
33    }
34
35    /// Returns an iterator of the file in the diff.
36    pub fn files(&self) -> impl Iterator<Item = &FileDiff> {
37        self.files.iter()
38    }
39
40    /// Returns owned files in the diff.
41    pub fn into_files(self) -> Vec<FileDiff> {
42        self.files
43    }
44
45    pub fn added(&self) -> impl Iterator<Item = &Added> {
46        self.files().filter_map(|x| match x {
47            FileDiff::Added(a) => Some(a),
48            _ => None,
49        })
50    }
51
52    pub fn deleted(&self) -> impl Iterator<Item = &Deleted> {
53        self.files().filter_map(|x| match x {
54            FileDiff::Deleted(a) => Some(a),
55            _ => None,
56        })
57    }
58
59    pub fn moved(&self) -> impl Iterator<Item = &Moved> {
60        self.files().filter_map(|x| match x {
61            FileDiff::Moved(a) => Some(a),
62            _ => None,
63        })
64    }
65
66    pub fn modified(&self) -> impl Iterator<Item = &Modified> {
67        self.files().filter_map(|x| match x {
68            FileDiff::Modified(a) => Some(a),
69            _ => None,
70        })
71    }
72
73    pub fn copied(&self) -> impl Iterator<Item = &Copied> {
74        self.files().filter_map(|x| match x {
75            FileDiff::Copied(a) => Some(a),
76            _ => None,
77        })
78    }
79
80    pub fn stats(&self) -> &Stats {
81        &self.stats
82    }
83
84    fn update_stats(&mut self, diff: &DiffContent) {
85        self.stats.files_changed += 1;
86        if let DiffContent::Plain { hunks, .. } = diff {
87            for h in hunks.iter() {
88                for l in &h.lines {
89                    match l {
90                        Modification::Addition(_) => self.stats.insertions += 1,
91                        Modification::Deletion(_) => self.stats.deletions += 1,
92                        _ => (),
93                    }
94                }
95            }
96        }
97    }
98
99    pub fn insert_modified(
100        &mut self,
101        path: PathBuf,
102        diff: DiffContent,
103        old: DiffFile,
104        new: DiffFile,
105    ) {
106        self.update_stats(&diff);
107        let diff = FileDiff::Modified(Modified {
108            path,
109            diff,
110            old,
111            new,
112        });
113        self.files.push(diff);
114    }
115
116    pub fn insert_moved(
117        &mut self,
118        old_path: PathBuf,
119        new_path: PathBuf,
120        old: DiffFile,
121        new: DiffFile,
122        content: DiffContent,
123    ) {
124        self.update_stats(&DiffContent::Empty);
125        let diff = FileDiff::Moved(Moved {
126            old_path,
127            new_path,
128            old,
129            new,
130            diff: content,
131        });
132        self.files.push(diff);
133    }
134
135    pub fn insert_copied(
136        &mut self,
137        old_path: PathBuf,
138        new_path: PathBuf,
139        old: DiffFile,
140        new: DiffFile,
141        content: DiffContent,
142    ) {
143        self.update_stats(&DiffContent::Empty);
144        let diff = FileDiff::Copied(Copied {
145            old_path,
146            new_path,
147            old,
148            new,
149            diff: content,
150        });
151        self.files.push(diff);
152    }
153
154    pub fn insert_added(&mut self, path: PathBuf, diff: DiffContent, new: DiffFile) {
155        self.update_stats(&diff);
156        let diff = FileDiff::Added(Added { path, diff, new });
157        self.files.push(diff);
158    }
159
160    pub fn insert_deleted(&mut self, path: PathBuf, diff: DiffContent, old: DiffFile) {
161        self.update_stats(&diff);
162        let diff = FileDiff::Deleted(Deleted { path, diff, old });
163        self.files.push(diff);
164    }
165}
166
167/// A file that was added within a [`Diff`].
168#[cfg_attr(feature = "serde", derive(Serialize))]
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct Added {
171    /// The path to this file, relative to the repository root.
172    pub path: PathBuf,
173    pub diff: DiffContent,
174    pub new: DiffFile,
175}
176
177/// A file that was deleted within a [`Diff`].
178#[cfg_attr(feature = "serde", derive(Serialize))]
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct Deleted {
181    /// The path to this file, relative to the repository root.
182    pub path: PathBuf,
183    pub diff: DiffContent,
184    pub old: DiffFile,
185}
186
187/// A file that was moved within a [`Diff`].
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub struct Moved {
190    /// The old path to this file, relative to the repository root.
191    pub old_path: PathBuf,
192    pub old: DiffFile,
193    /// The new path to this file, relative to the repository root.
194    pub new_path: PathBuf,
195    pub new: DiffFile,
196    pub diff: DiffContent,
197}
198
199#[cfg(feature = "serde")]
200impl Serialize for Moved {
201    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
202    where
203        S: Serializer,
204    {
205        if self.old == self.new {
206            let mut state = serializer.serialize_struct("Moved", 3)?;
207            state.serialize_field("oldPath", &self.old_path)?;
208            state.serialize_field("newPath", &self.new_path)?;
209            state.serialize_field("current", &self.new)?;
210            state.end()
211        } else {
212            let mut state = serializer.serialize_struct("Moved", 5)?;
213            state.serialize_field("oldPath", &self.old_path)?;
214            state.serialize_field("newPath", &self.new_path)?;
215            state.serialize_field("old", &self.old)?;
216            state.serialize_field("new", &self.new)?;
217            state.serialize_field("diff", &self.diff)?;
218            state.end()
219        }
220    }
221}
222
223/// A file that was copied within a [`Diff`].
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct Copied {
226    /// The old path to this file, relative to the repository root.
227    pub old_path: PathBuf,
228    /// The new path to this file, relative to the repository root.
229    pub new_path: PathBuf,
230    pub old: DiffFile,
231    pub new: DiffFile,
232    pub diff: DiffContent,
233}
234
235#[cfg(feature = "serde")]
236impl Serialize for Copied {
237    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
238    where
239        S: Serializer,
240    {
241        if self.old == self.new {
242            let mut state = serializer.serialize_struct("Copied", 3)?;
243            state.serialize_field("oldPath", &self.old_path)?;
244            state.serialize_field("newPath", &self.new_path)?;
245            state.serialize_field("current", &self.new)?;
246            state.end()
247        } else {
248            let mut state = serializer.serialize_struct("Copied", 5)?;
249            state.serialize_field("oldPath", &self.old_path)?;
250            state.serialize_field("newPath", &self.new_path)?;
251            state.serialize_field("old", &self.old)?;
252            state.serialize_field("new", &self.new)?;
253            state.serialize_field("diff", &self.diff)?;
254            state.end()
255        }
256    }
257}
258
259#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
260#[derive(Clone, Debug, PartialEq, Eq, Default)]
261pub enum EofNewLine {
262    OldMissing,
263    NewMissing,
264    BothMissing,
265    #[default]
266    NoneMissing,
267}
268
269/// A file that was modified within a [`Diff`].
270#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub struct Modified {
273    pub path: PathBuf,
274    pub diff: DiffContent,
275    pub old: DiffFile,
276    pub new: DiffFile,
277}
278
279/// The set of changes for a given file.
280#[cfg_attr(
281    feature = "serde",
282    derive(Serialize),
283    serde(tag = "type", rename_all = "camelCase")
284)]
285#[derive(Clone, Debug, PartialEq, Eq)]
286pub enum DiffContent {
287    /// The file is a binary file and so no set of changes can be provided.
288    Binary,
289    /// The set of changes, as [`Hunks`] for a plaintext file.
290    #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
291    Plain {
292        hunks: Hunks<Modification>,
293        stats: FileStats,
294        eof: EofNewLine,
295    },
296    Empty,
297}
298
299impl DiffContent {
300    pub fn eof(&self) -> Option<EofNewLine> {
301        match self {
302            Self::Plain { eof, .. } => Some(eof.clone()),
303            _ => None,
304        }
305    }
306
307    pub fn stats(&self) -> Option<&FileStats> {
308        match &self {
309            DiffContent::Plain { stats, .. } => Some(stats),
310            DiffContent::Empty => None,
311            DiffContent::Binary => None,
312        }
313    }
314}
315
316/// File mode in a diff.
317#[derive(Clone, Debug, PartialEq, Eq)]
318#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
319pub enum FileMode {
320    /// For regular files.
321    Blob,
322    /// For regular files that are executable.
323    BlobExecutable,
324    /// For directories.
325    Tree,
326    /// For symbolic links.
327    Link,
328    /// Used for Git submodules.
329    Commit,
330}
331
332impl From<FileMode> for u32 {
333    fn from(m: FileMode) -> Self {
334        git2::FileMode::from(m).into()
335    }
336}
337
338impl From<FileMode> for i32 {
339    fn from(m: FileMode) -> Self {
340        git2::FileMode::from(m).into()
341    }
342}
343
344/// A modified file.
345#[derive(Clone, Debug, PartialEq, Eq)]
346#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
347pub struct DiffFile {
348    /// File blob id.
349    pub oid: Oid,
350    /// File mode.
351    pub mode: FileMode,
352}
353
354#[derive(Clone, Debug, PartialEq, Eq)]
355#[cfg_attr(
356    feature = "serde",
357    derive(Serialize),
358    serde(tag = "status", rename_all = "camelCase")
359)]
360pub enum FileDiff {
361    Added(Added),
362    Deleted(Deleted),
363    Modified(Modified),
364    Moved(Moved),
365    Copied(Copied),
366}
367
368impl FileDiff {
369    pub fn path(&self) -> &Path {
370        match self {
371            FileDiff::Added(x) => x.path.as_path(),
372            FileDiff::Deleted(x) => x.path.as_path(),
373            FileDiff::Modified(x) => x.path.as_path(),
374            FileDiff::Moved(x) => x.new_path.as_path(),
375            FileDiff::Copied(x) => x.new_path.as_path(),
376        }
377    }
378}
379
380/// Statistics describing a particular [`FileDiff`].
381#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
382#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
383pub struct FileStats {
384    /// Get the total number of additions in a [`FileDiff`].
385    pub additions: usize,
386    /// Get the total number of deletions in a [`FileDiff`].
387    pub deletions: usize,
388}
389
390/// Statistics describing a particular [`Diff`].
391#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
392#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
393pub struct Stats {
394    /// Get the total number of files changed in a [`Diff`]
395    pub files_changed: usize,
396    /// Get the total number of insertions in a [`Diff`].
397    pub insertions: usize,
398    /// Get the total number of deletions in a [`Diff`].
399    pub deletions: usize,
400}
401
402/// A set of changes across multiple lines.
403///
404/// The parameter `T` can be an [`Addition`], [`Deletion`], or
405/// [`Modification`].
406#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
407#[derive(Clone, Debug, PartialEq, Eq)]
408pub struct Hunk<T> {
409    pub header: Line,
410    pub lines: Vec<T>,
411    /// Old line range.
412    pub old: Range<u32>,
413    /// New line range.
414    pub new: Range<u32>,
415}
416
417/// A set of [`Hunk`] changes.
418#[cfg_attr(feature = "serde", derive(Serialize))]
419#[derive(Clone, Debug, PartialEq, Eq)]
420pub struct Hunks<T>(pub Vec<Hunk<T>>);
421
422impl<T> Default for Hunks<T> {
423    fn default() -> Self {
424        Self(Default::default())
425    }
426}
427
428impl<T> Hunks<T> {
429    pub fn iter(&self) -> impl Iterator<Item = &Hunk<T>> {
430        self.0.iter()
431    }
432}
433
434impl<T> From<Vec<Hunk<T>>> for Hunks<T> {
435    fn from(hunks: Vec<Hunk<T>>) -> Self {
436        Self(hunks)
437    }
438}
439
440/// The content of a single line.
441#[derive(Clone, Debug, PartialEq, Eq)]
442pub struct Line(pub(crate) Vec<u8>);
443
444impl Line {
445    pub fn as_bytes(&self) -> &[u8] {
446        self.0.as_slice()
447    }
448
449    pub fn from_utf8(self) -> Result<String, FromUtf8Error> {
450        String::from_utf8(self.0)
451    }
452
453    pub fn from_utf8_lossy<'a>(&'a self) -> Cow<'a, str> {
454        String::from_utf8_lossy(&self.0)
455    }
456}
457
458impl From<Vec<u8>> for Line {
459    fn from(v: Vec<u8>) -> Self {
460        Self(v)
461    }
462}
463
464impl From<String> for Line {
465    fn from(s: String) -> Self {
466        Self(s.into_bytes())
467    }
468}
469
470#[cfg(feature = "serde")]
471impl Serialize for Line {
472    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
473    where
474        S: Serializer,
475    {
476        let s = std::str::from_utf8(&self.0).map_err(ser::Error::custom)?;
477
478        serializer.serialize_str(s)
479    }
480}
481
482/// Either the modification of a single [`Line`], or just contextual
483/// information.
484#[derive(Clone, Debug, PartialEq, Eq)]
485pub enum Modification {
486    /// A line is an addition in a file.
487    Addition(Addition),
488
489    /// A line is a deletion in a file.
490    Deletion(Deletion),
491
492    /// A contextual line in a file, i.e. there were no changes to the line.
493    Context {
494        line: Line,
495        line_no_old: u32,
496        line_no_new: u32,
497    },
498}
499
500#[cfg(feature = "serde")]
501impl Serialize for Modification {
502    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
503    where
504        S: Serializer,
505    {
506        use serde::ser::SerializeMap as _;
507
508        match self {
509            Modification::Addition(addition) => {
510                let mut map = serializer.serialize_map(Some(3))?;
511                map.serialize_entry("line", &addition.line)?;
512                map.serialize_entry("lineNo", &addition.line_no)?;
513                map.serialize_entry("type", "addition")?;
514                map.end()
515            }
516            Modification::Deletion(deletion) => {
517                let mut map = serializer.serialize_map(Some(3))?;
518                map.serialize_entry("line", &deletion.line)?;
519                map.serialize_entry("lineNo", &deletion.line_no)?;
520                map.serialize_entry("type", "deletion")?;
521                map.end()
522            }
523            Modification::Context {
524                line,
525                line_no_old,
526                line_no_new,
527            } => {
528                let mut map = serializer.serialize_map(Some(4))?;
529                map.serialize_entry("line", line)?;
530                map.serialize_entry("lineNoOld", line_no_old)?;
531                map.serialize_entry("lineNoNew", line_no_new)?;
532                map.serialize_entry("type", "context")?;
533                map.end()
534            }
535        }
536    }
537}
538
539/// A addition of a [`Line`] at the `line_no`.
540#[derive(Clone, Debug, PartialEq, Eq)]
541pub struct Addition {
542    pub line: Line,
543    pub line_no: u32,
544}
545
546#[cfg(feature = "serde")]
547impl Serialize for Addition {
548    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
549    where
550        S: Serializer,
551    {
552        use serde::ser::SerializeStruct as _;
553
554        let mut s = serializer.serialize_struct("Addition", 3)?;
555        s.serialize_field("line", &self.line)?;
556        s.serialize_field("lineNo", &self.line_no)?;
557        s.serialize_field("type", "addition")?;
558        s.end()
559    }
560}
561
562/// A deletion of a [`Line`] at the `line_no`.
563#[derive(Clone, Debug, PartialEq, Eq)]
564pub struct Deletion {
565    pub line: Line,
566    pub line_no: u32,
567}
568
569#[cfg(feature = "serde")]
570impl Serialize for Deletion {
571    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
572    where
573        S: Serializer,
574    {
575        use serde::ser::SerializeStruct as _;
576
577        let mut s = serializer.serialize_struct("Deletion", 3)?;
578        s.serialize_field("line", &self.line)?;
579        s.serialize_field("lineNo", &self.line_no)?;
580        s.serialize_field("type", "deletion")?;
581        s.end()
582    }
583}
584
585impl Modification {
586    pub fn addition(line: impl Into<Line>, line_no: u32) -> Self {
587        Self::Addition(Addition {
588            line: line.into(),
589            line_no,
590        })
591    }
592
593    pub fn deletion(line: impl Into<Line>, line_no: u32) -> Self {
594        Self::Deletion(Deletion {
595            line: line.into(),
596            line_no,
597        })
598    }
599
600    pub fn context(line: impl Into<Line>, line_no_old: u32, line_no_new: u32) -> Self {
601        Self::Context {
602            line: line.into(),
603            line_no_old,
604            line_no_new,
605        }
606    }
607}