Skip to main content

objects/object/
diff.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared file change types for tree diffing.
3//!
4//! This module provides common types used across the codebase for representing
5//! file-level changes between two trees or worktree states.
6
7use std::fmt;
8
9/// Kind of file change.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum DiffKind {
12    /// File was added.
13    Added,
14    /// File was modified.
15    Modified,
16    /// File was deleted.
17    Deleted,
18    /// File is unchanged (used in some comparison contexts).
19    #[default]
20    Unchanged,
21}
22
23impl DiffKind {
24    /// Returns true if this kind represents an actual change.
25    pub fn is_change(&self) -> bool {
26        !matches!(self, DiffKind::Unchanged)
27    }
28}
29
30impl fmt::Display for DiffKind {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            DiffKind::Added => write!(f, "added"),
34            DiffKind::Modified => write!(f, "modified"),
35            DiffKind::Deleted => write!(f, "deleted"),
36            DiffKind::Unchanged => write!(f, "unchanged"),
37        }
38    }
39}
40
41/// A single file change with path and kind.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct FileChange {
44    /// Path to the file (relative to repository root).
45    pub path: String,
46    /// Kind of change.
47    pub kind: DiffKind,
48}
49
50impl FileChange {
51    /// Create a new file change.
52    pub fn new(path: impl Into<String>, kind: DiffKind) -> Self {
53        Self {
54            path: path.into(),
55            kind,
56        }
57    }
58
59    /// Create an added file change.
60    pub fn added(path: impl Into<String>) -> Self {
61        Self::new(path, DiffKind::Added)
62    }
63
64    /// Create a modified file change.
65    pub fn modified(path: impl Into<String>) -> Self {
66        Self::new(path, DiffKind::Modified)
67    }
68
69    /// Create a deleted file change.
70    pub fn deleted(path: impl Into<String>) -> Self {
71        Self::new(path, DiffKind::Deleted)
72    }
73
74    /// Convert to tuple representation.
75    pub fn into_tuple(self) -> (String, DiffKind) {
76        (self.path, self.kind)
77    }
78
79    /// Convert from tuple representation.
80    pub fn from_tuple((path, kind): (String, DiffKind)) -> Self {
81        Self { path, kind }
82    }
83}
84
85impl From<(String, DiffKind)> for FileChange {
86    fn from(tuple: (String, DiffKind)) -> Self {
87        Self::from_tuple(tuple)
88    }
89}
90
91impl From<FileChange> for (String, DiffKind) {
92    fn from(change: FileChange) -> Self {
93        change.into_tuple()
94    }
95}
96
97/// A collection of file changes with convenience accessors.
98#[derive(Debug, Clone, Default)]
99pub struct FileChangeSet {
100    changes: Vec<FileChange>,
101}
102
103impl FileChangeSet {
104    /// Create a new empty file change set.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Create a new file change set with capacity.
110    pub fn with_capacity(capacity: usize) -> Self {
111        Self {
112            changes: Vec::with_capacity(capacity),
113        }
114    }
115
116    /// Add a file change.
117    pub fn push(&mut self, change: FileChange) {
118        self.changes.push(change);
119    }
120
121    /// Add an added file change.
122    pub fn push_added(&mut self, path: impl Into<String>) {
123        self.changes.push(FileChange::added(path));
124    }
125
126    /// Add a modified file change.
127    pub fn push_modified(&mut self, path: impl Into<String>) {
128        self.changes.push(FileChange::modified(path));
129    }
130
131    /// Add a deleted file change.
132    pub fn push_deleted(&mut self, path: impl Into<String>) {
133        self.changes.push(FileChange::deleted(path));
134    }
135
136    /// Returns true if there are no changes.
137    pub fn is_empty(&self) -> bool {
138        self.changes.is_empty()
139    }
140
141    /// Returns the number of changes.
142    pub fn len(&self) -> usize {
143        self.changes.len()
144    }
145
146    /// Returns an iterator over the changes.
147    pub fn iter(&self) -> impl Iterator<Item = &FileChange> {
148        self.changes.iter()
149    }
150
151    /// Returns a mutable iterator over the changes.
152    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut FileChange> {
153        self.changes.iter_mut()
154    }
155
156    /// Consume and return the underlying vector.
157    pub fn into_vec(self) -> Vec<FileChange> {
158        self.changes
159    }
160
161    /// Get only added files.
162    pub fn added(&self) -> impl Iterator<Item = &FileChange> {
163        self.changes.iter().filter(|c| c.kind == DiffKind::Added)
164    }
165
166    /// Get only modified files.
167    pub fn modified(&self) -> impl Iterator<Item = &FileChange> {
168        self.changes.iter().filter(|c| c.kind == DiffKind::Modified)
169    }
170
171    /// Get only deleted files.
172    pub fn deleted(&self) -> impl Iterator<Item = &FileChange> {
173        self.changes.iter().filter(|c| c.kind == DiffKind::Deleted)
174    }
175
176    /// Returns true if there are no changes.
177    pub fn is_clean(&self) -> bool {
178        self.changes.is_empty()
179    }
180
181    /// Returns the number of added files.
182    pub fn added_count(&self) -> usize {
183        self.added().count()
184    }
185
186    /// Returns the number of modified files.
187    pub fn modified_count(&self) -> usize {
188        self.modified().count()
189    }
190
191    /// Returns the number of deleted files.
192    pub fn deleted_count(&self) -> usize {
193        self.deleted().count()
194    }
195}
196
197impl Extend<FileChange> for FileChangeSet {
198    fn extend<T: IntoIterator<Item = FileChange>>(&mut self, iter: T) {
199        self.changes.extend(iter);
200    }
201}
202
203impl From<Vec<FileChange>> for FileChangeSet {
204    fn from(changes: Vec<FileChange>) -> Self {
205        Self { changes }
206    }
207}
208
209impl From<Vec<(String, DiffKind)>> for FileChangeSet {
210    fn from(changes: Vec<(String, DiffKind)>) -> Self {
211        Self {
212            changes: changes.into_iter().map(FileChange::from_tuple).collect(),
213        }
214    }
215}
216
217impl IntoIterator for FileChangeSet {
218    type Item = FileChange;
219    type IntoIter = std::vec::IntoIter<FileChange>;
220
221    fn into_iter(self) -> Self::IntoIter {
222        self.changes.into_iter()
223    }
224}
225
226impl<'a> IntoIterator for &'a FileChangeSet {
227    type Item = &'a FileChange;
228    type IntoIter = std::slice::Iter<'a, FileChange>;
229
230    fn into_iter(self) -> Self::IntoIter {
231        self.changes.iter()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_file_change_creation() {
241        let change = FileChange::added("src/main.rs");
242        assert_eq!(change.path, "src/main.rs");
243        assert_eq!(change.kind, DiffKind::Added);
244
245        let change = FileChange::modified("src/lib.rs");
246        assert_eq!(change.kind, DiffKind::Modified);
247
248        let change = FileChange::deleted("old.txt");
249        assert_eq!(change.kind, DiffKind::Deleted);
250    }
251
252    #[test]
253    fn test_file_change_tuple_conversion() {
254        let change = FileChange::added("foo.txt");
255        let tuple: (String, DiffKind) = change.into();
256        assert_eq!(tuple, (String::from("foo.txt"), DiffKind::Added));
257
258        let change: FileChange = (String::from("bar.txt"), DiffKind::Modified).into();
259        assert_eq!(change.path, "bar.txt");
260        assert_eq!(change.kind, DiffKind::Modified);
261    }
262
263    #[test]
264    fn test_file_change_set_basic() {
265        let mut set = FileChangeSet::new();
266        assert!(set.is_empty());
267        assert_eq!(set.len(), 0);
268
269        set.push_added("a.txt");
270        set.push_modified("b.txt");
271        set.push_deleted("c.txt");
272
273        assert!(!set.is_empty());
274        assert_eq!(set.len(), 3);
275        assert_eq!(set.added_count(), 1);
276        assert_eq!(set.modified_count(), 1);
277        assert_eq!(set.deleted_count(), 1);
278    }
279
280    #[test]
281    fn test_file_change_set_iterators() {
282        let mut set = FileChangeSet::new();
283        set.push_added("a.txt");
284        set.push_modified("b.txt");
285        set.push_deleted("c.txt");
286
287        let added: Vec<_> = set.added().map(|c| &c.path).collect();
288        assert_eq!(added, vec!["a.txt"]);
289
290        let modified: Vec<_> = set.modified().map(|c| &c.path).collect();
291        assert_eq!(modified, vec!["b.txt"]);
292
293        let deleted: Vec<_> = set.deleted().map(|c| &c.path).collect();
294        assert_eq!(deleted, vec!["c.txt"]);
295    }
296
297    #[test]
298    fn test_file_change_set_conversion() {
299        let tuples = vec![
300            (String::from("a.txt"), DiffKind::Added),
301            (String::from("b.txt"), DiffKind::Modified),
302        ];
303        let set = FileChangeSet::from(tuples);
304
305        assert_eq!(set.len(), 2);
306        assert_eq!(set.added_count(), 1);
307        assert_eq!(set.modified_count(), 1);
308    }
309
310    #[test]
311    fn test_diff_kind_display() {
312        assert_eq!(DiffKind::Added.to_string(), "added");
313        assert_eq!(DiffKind::Modified.to_string(), "modified");
314        assert_eq!(DiffKind::Deleted.to_string(), "deleted");
315        assert_eq!(DiffKind::Unchanged.to_string(), "unchanged");
316    }
317
318    #[test]
319    fn test_diff_kind_is_change() {
320        assert!(!DiffKind::Unchanged.is_change());
321        assert!(DiffKind::Added.is_change());
322        assert!(DiffKind::Modified.is_change());
323        assert!(DiffKind::Deleted.is_change());
324    }
325}