Skip to main content

clankerdiff_core/models/
file_diff.rs

1use super::{DiffSide, Hunk, PatchLine, RepoPath, patch_derivation::derive_patch};
2use crate::{
3    DiffError, Fingerprint, RepoPathError, SourceDocument, SourceResult, SourceUnavailable,
4};
5use serde::{Deserialize, Serialize};
6use std::{borrow::Cow, ffi::OsStr, path::Path, sync::Arc};
7
8const FILE_CONTENT_DOMAIN: &[u8] = b"diff-file-content-v1";
9
10/// Git's file operation classification.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum FileStatus {
13    Modified,
14    Added,
15    Deleted,
16    Renamed,
17    Copied,
18    Untracked,
19}
20
21impl FileStatus {
22    #[must_use]
23    pub const fn code(self) -> char {
24        match self {
25            Self::Modified => 'M',
26            Self::Added => 'A',
27            Self::Deleted => 'D',
28            Self::Renamed => 'R',
29            Self::Copied => 'C',
30            Self::Untracked => '?',
31        }
32    }
33}
34
35/// Index/worktree staging state.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37pub enum StageState {
38    Unstaged,
39    Staged,
40    PartiallyStaged,
41}
42
43/// A file mode change, represented as Git's six-digit mode string.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ModeChange {
46    pub old: Option<String>,
47    pub new: Option<String>,
48}
49
50/// One changed file.
51///
52/// `old_source` and `new_source` hold the complete versions of each side.
53/// When both are available the hunks are derived from them, so equal sides
54/// imply equal hunks; otherwise the hunks are the patch as supplied.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct FileDiff {
57    pub old_path: Option<RepoPath>,
58    pub path: RepoPath,
59    pub status: FileStatus,
60    pub staged: StageState,
61    pub hunks: Vec<Hunk>,
62    pub binary: bool,
63    pub mode: Option<ModeChange>,
64    pub no_newline_at_end: bool,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub omitted_bytes: Option<u64>,
67    #[serde(default = "not_captured", skip_serializing_if = "is_not_captured")]
68    pub old_source: SourceResult,
69    #[serde(default = "not_captured", skip_serializing_if = "is_not_captured")]
70    pub new_source: SourceResult,
71}
72
73fn not_captured() -> SourceResult {
74    Err(SourceUnavailable::NotCaptured)
75}
76
77fn is_not_captured(source: &SourceResult) -> bool {
78    matches!(source, Err(SourceUnavailable::NotCaptured))
79}
80
81impl FileDiff {
82    /// Returns the repository path used by one side of this file version.
83    #[must_use]
84    pub fn path_for_side(&self, side: DiffSide) -> &RepoPath {
85        match side {
86            DiffSide::Old => self.old_path.as_ref().unwrap_or(&self.path),
87            DiffSide::New => &self.path,
88        }
89    }
90
91    /// Builds a one-file diff from complete old and new text snapshots.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error when `path` is not a valid repository-relative path.
96    pub fn from_texts<T>(path: T, old: &str, new: &str) -> Result<Self, DiffError>
97    where
98        T: TryInto<RepoPath>,
99        T::Error: Into<RepoPathError>,
100    {
101        let path = path
102            .try_into()
103            .map_err(|error| DiffError::InvalidPath(error.into()))?;
104        let status = match (old.is_empty(), new.is_empty()) {
105            (true, false) => FileStatus::Added,
106            (false, true) => FileStatus::Deleted,
107            _ => FileStatus::Modified,
108        };
109        let (hunks, no_newline_at_end) = derive_patch(old, new);
110        let side = |text: &str, absent: bool| {
111            if absent {
112                Err(SourceUnavailable::Absent)
113            } else {
114                SourceDocument::new(text).map(Arc::new)
115            }
116        };
117        Ok(Self {
118            old_path: (status != FileStatus::Added).then(|| path.clone()),
119            path,
120            status,
121            staged: StageState::Unstaged,
122            hunks,
123            binary: false,
124            mode: None,
125            no_newline_at_end,
126            omitted_bytes: None,
127            old_source: side(old, status == FileStatus::Added),
128            new_source: side(new, status == FileStatus::Deleted),
129        })
130    }
131
132    /// Attaches captured source versions, re-deriving the hunks from them when
133    /// both sides are known. Git metadata such as status, staging, mode, and
134    /// paths is kept as supplied; a binary file keeps its patch untouched.
135    #[must_use]
136    pub fn with_sources(mut self, old: SourceResult, new: SourceResult) -> Self {
137        self.old_source = old;
138        self.new_source = new;
139        if !self.can_derive_patch() {
140            return self;
141        }
142        if let (Some(old), Some(new)) =
143            (self.side_text(DiffSide::Old), self.side_text(DiffSide::New))
144        {
145            let (hunks, no_newline_at_end) = derive_patch(old, new);
146            self.hunks = hunks;
147            self.no_newline_at_end = no_newline_at_end;
148        }
149        self
150    }
151
152    fn can_derive_patch(&self) -> bool {
153        !self.binary
154            && self.side_text(DiffSide::Old).is_some()
155            && self.side_text(DiffSide::New).is_some()
156    }
157
158    /// The source result recorded for a side of a file whose complete versions
159    /// were not captured.
160    ///
161    /// # Errors
162    /// Always an unavailable reason: [`SourceUnavailable::Absent`] where the
163    /// status says the side cannot exist, [`SourceUnavailable::NotCaptured`]
164    /// otherwise.
165    pub const fn uncaptured_source(status: FileStatus, side: DiffSide) -> SourceResult {
166        if Self::side_is_absent(status, side) {
167            Err(SourceUnavailable::Absent)
168        } else {
169            Err(SourceUnavailable::NotCaptured)
170        }
171    }
172
173    const fn side_is_absent(status: FileStatus, side: DiffSide) -> bool {
174        matches!(
175            (status, side),
176            (FileStatus::Added | FileStatus::Untracked, DiffSide::Old)
177                | (FileStatus::Deleted, DiffSide::New)
178        )
179    }
180
181    /// Returns the complete source result for one side.
182    pub const fn source(&self, side: DiffSide) -> &SourceResult {
183        match side {
184            DiffSide::Old => &self.old_source,
185            DiffSide::New => &self.new_source,
186        }
187    }
188
189    pub(crate) const fn source_side(&self) -> DiffSide {
190        if matches!(self.status, FileStatus::Deleted) {
191            DiffSide::Old
192        } else {
193            DiffSide::New
194        }
195    }
196
197    /// Returns the complete source document for one side when it is available.
198    #[must_use]
199    pub fn source_document(&self, side: DiffSide) -> Option<&Arc<SourceDocument>> {
200        self.source(side).as_ref().ok()
201    }
202
203    /// Returns why one side has no complete source document.
204    #[must_use]
205    pub fn source_unavailable(&self, side: DiffSide) -> Option<&SourceUnavailable> {
206        self.source(side).as_ref().err()
207    }
208
209    /// Complete text for a side: the captured source, or empty where the
210    /// status says the side does not exist.
211    fn side_text(&self, side: DiffSide) -> Option<&str> {
212        match self.source(side) {
213            Ok(source) => Some(source.text()),
214            Err(SourceUnavailable::Absent) if Self::side_is_absent(self.status, side) => Some(""),
215            Err(_) => None,
216        }
217    }
218
219    /// Content identity used to decide whether presentation state derived from
220    /// this file, such as revealed gaps, still applies after a replacement.
221    ///
222    /// The identity covers both sides. When either side is unavailable the
223    /// hunks are the content and are covered too; when both are available the
224    /// hunks are derived from them and add nothing.
225    #[must_use]
226    pub fn content_id(&self) -> Fingerprint {
227        let mut fields: Vec<Cow<'_, [u8]>> = vec![
228            Cow::Borrowed(FILE_CONTENT_DOMAIN),
229            Cow::Owned(vec![
230                u8::try_from(self.status.code()).unwrap_or(b'?'),
231                u8::from(self.binary),
232            ]),
233        ];
234        let mut complete = true;
235        for side in [DiffSide::Old, DiffSide::New] {
236            match self.source(side) {
237                Ok(source) => fields.push(Cow::Owned(source.content_id().as_bytes().to_vec())),
238                Err(reason) => {
239                    complete = false;
240                    fields.push(Cow::Owned(reason.to_string().into_bytes()));
241                }
242            }
243        }
244        if !complete {
245            for hunk in &self.hunks {
246                fields.push(Cow::Borrowed(hunk.header.as_bytes()));
247                for line in &hunk.lines {
248                    fields.push(Cow::Borrowed(line.kind.as_str().as_bytes()));
249                    fields.push(Cow::Borrowed(line.text.as_bytes()));
250                    fields.push(Cow::Owned(
251                        [line.old_line_no, line.new_line_no]
252                            .iter()
253                            .flat_map(|number| number.unwrap_or(0).to_le_bytes())
254                            .chain([u8::from(line.no_newline)])
255                            .collect(),
256                    ));
257                }
258            }
259        }
260        Fingerprint::of(fields)
261    }
262
263    /// Number of added lines.
264    #[must_use]
265    pub fn additions(&self) -> usize {
266        self.hunks.iter().map(Hunk::additions).sum()
267    }
268
269    /// Number of removed lines.
270    #[must_use]
271    pub fn deletions(&self) -> usize {
272        self.hunks.iter().map(Hunk::deletions).sum()
273    }
274
275    /// Lowercase file extension, without a dot.
276    #[must_use]
277    pub fn language(&self) -> &str {
278        Path::new(self.path.as_str())
279            .extension()
280            .and_then(OsStr::to_str)
281            .unwrap_or_default()
282    }
283
284    #[must_use]
285    pub fn line(&self, hunk: usize, line: usize) -> Option<&PatchLine> {
286        self.hunks.get(hunk)?.lines.get(line)
287    }
288}