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.binary {
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    /// The source result recorded for a side of a file whose complete versions
153    /// were not captured.
154    ///
155    /// # Errors
156    /// Always an unavailable reason: [`SourceUnavailable::Absent`] where the
157    /// status says the side cannot exist, [`SourceUnavailable::NotCaptured`]
158    /// otherwise.
159    pub const fn uncaptured_source(status: FileStatus, side: DiffSide) -> SourceResult {
160        if Self::side_is_absent(status, side) {
161            Err(SourceUnavailable::Absent)
162        } else {
163            Err(SourceUnavailable::NotCaptured)
164        }
165    }
166
167    const fn side_is_absent(status: FileStatus, side: DiffSide) -> bool {
168        matches!(
169            (status, side),
170            (FileStatus::Added | FileStatus::Untracked, DiffSide::Old)
171                | (FileStatus::Deleted, DiffSide::New)
172        )
173    }
174
175    /// Returns the complete source result for one side.
176    pub const fn source(&self, side: DiffSide) -> &SourceResult {
177        match side {
178            DiffSide::Old => &self.old_source,
179            DiffSide::New => &self.new_source,
180        }
181    }
182
183    /// Returns the complete source document for one side when it is available.
184    #[must_use]
185    pub fn source_document(&self, side: DiffSide) -> Option<&Arc<SourceDocument>> {
186        self.source(side).as_ref().ok()
187    }
188
189    /// Returns why one side has no complete source document.
190    #[must_use]
191    pub fn source_unavailable(&self, side: DiffSide) -> Option<&SourceUnavailable> {
192        self.source(side).as_ref().err()
193    }
194
195    /// Complete text for a side: the captured source, or empty where the
196    /// status says the side does not exist.
197    fn side_text(&self, side: DiffSide) -> Option<&str> {
198        match self.source(side) {
199            Ok(source) => Some(source.text()),
200            Err(SourceUnavailable::Absent) if Self::side_is_absent(self.status, side) => Some(""),
201            Err(_) => None,
202        }
203    }
204
205    /// Content identity used to decide whether presentation state derived from
206    /// this file, such as revealed gaps, still applies after a replacement.
207    ///
208    /// The identity covers both sides. When either side is unavailable the
209    /// hunks are the content and are covered too; when both are available the
210    /// hunks are derived from them and add nothing.
211    #[must_use]
212    pub fn content_id(&self) -> Fingerprint {
213        let mut fields: Vec<Cow<'_, [u8]>> = vec![
214            Cow::Borrowed(FILE_CONTENT_DOMAIN),
215            Cow::Owned(vec![
216                u8::try_from(self.status.code()).unwrap_or(b'?'),
217                u8::from(self.binary),
218            ]),
219        ];
220        let mut complete = true;
221        for side in [DiffSide::Old, DiffSide::New] {
222            match self.source(side) {
223                Ok(source) => fields.push(Cow::Owned(source.content_id().as_bytes().to_vec())),
224                Err(reason) => {
225                    complete = false;
226                    fields.push(Cow::Owned(reason.to_string().into_bytes()));
227                }
228            }
229        }
230        if !complete {
231            for hunk in &self.hunks {
232                fields.push(Cow::Borrowed(hunk.header.as_bytes()));
233                for line in &hunk.lines {
234                    fields.push(Cow::Borrowed(line.kind.as_str().as_bytes()));
235                    fields.push(Cow::Borrowed(line.text.as_bytes()));
236                    fields.push(Cow::Owned(
237                        [line.old_line_no, line.new_line_no]
238                            .iter()
239                            .flat_map(|number| number.unwrap_or(0).to_le_bytes())
240                            .chain([u8::from(line.no_newline)])
241                            .collect(),
242                    ));
243                }
244            }
245        }
246        Fingerprint::of(fields)
247    }
248
249    /// Number of added lines.
250    #[must_use]
251    pub fn additions(&self) -> usize {
252        self.hunks.iter().map(Hunk::additions).sum()
253    }
254
255    /// Number of removed lines.
256    #[must_use]
257    pub fn deletions(&self) -> usize {
258        self.hunks.iter().map(Hunk::deletions).sum()
259    }
260
261    /// Lowercase file extension, without a dot.
262    #[must_use]
263    pub fn language(&self) -> &str {
264        Path::new(self.path.as_str())
265            .extension()
266            .and_then(OsStr::to_str)
267            .unwrap_or_default()
268    }
269
270    #[must_use]
271    pub fn line(&self, hunk: usize, line: usize) -> Option<&PatchLine> {
272        self.hunks.get(hunk)?.lines.get(line)
273    }
274}