Skip to main content

lago_fs/
diff.rs

1use lago_core::ManifestEntry;
2use serde::{Deserialize, Serialize};
3
4use crate::manifest::Manifest;
5
6/// A single difference between two manifest versions.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub enum DiffEntry {
9    /// A path that exists in the new manifest but not the old.
10    Added { path: String, entry: ManifestEntry },
11    /// A path that exists in the old manifest but not the new.
12    Removed { path: String, entry: ManifestEntry },
13    /// A path that exists in both manifests but with a different blob_hash.
14    Modified {
15        path: String,
16        old: ManifestEntry,
17        new: ManifestEntry,
18    },
19}
20
21/// Compute the diff between two manifest versions.
22///
23/// Iterates over both manifests' sorted entries in lockstep (leveraging the
24/// BTreeMap ordering) and classifies each path as added, removed, or
25/// modified. Modification is detected by comparing `blob_hash` values.
26pub fn diff(old: &Manifest, new: &Manifest) -> Vec<DiffEntry> {
27    let mut result = Vec::new();
28
29    let old_entries = old.entries();
30    let new_entries = new.entries();
31
32    let mut old_iter = old_entries.iter().peekable();
33    let mut new_iter = new_entries.iter().peekable();
34
35    loop {
36        match (old_iter.peek(), new_iter.peek()) {
37            (Some((old_path, _)), Some((new_path, _))) => {
38                match old_path.cmp(new_path) {
39                    std::cmp::Ordering::Less => {
40                        // Path only in old => removed
41                        let (path, entry) = old_iter.next().unwrap();
42                        result.push(DiffEntry::Removed {
43                            path: path.clone(),
44                            entry: entry.clone(),
45                        });
46                    }
47                    std::cmp::Ordering::Greater => {
48                        // Path only in new => added
49                        let (path, entry) = new_iter.next().unwrap();
50                        result.push(DiffEntry::Added {
51                            path: path.clone(),
52                            entry: entry.clone(),
53                        });
54                    }
55                    std::cmp::Ordering::Equal => {
56                        // Path in both, check if modified
57                        let (path, old_entry) = old_iter.next().unwrap();
58                        let (_, new_entry) = new_iter.next().unwrap();
59                        if old_entry.blob_hash != new_entry.blob_hash {
60                            result.push(DiffEntry::Modified {
61                                path: path.clone(),
62                                old: old_entry.clone(),
63                                new: new_entry.clone(),
64                            });
65                        }
66                    }
67                }
68            }
69            (Some(_), None) => {
70                // Remaining old entries were removed
71                let (path, entry) = old_iter.next().unwrap();
72                result.push(DiffEntry::Removed {
73                    path: path.clone(),
74                    entry: entry.clone(),
75                });
76            }
77            (None, Some(_)) => {
78                // Remaining new entries were added
79                let (path, entry) = new_iter.next().unwrap();
80                result.push(DiffEntry::Added {
81                    path: path.clone(),
82                    entry: entry.clone(),
83                });
84            }
85            (None, None) => break,
86        }
87    }
88
89    result
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use lago_core::BlobHash;
96
97    #[test]
98    fn diff_empty_manifests() {
99        let old = Manifest::new();
100        let new = Manifest::new();
101        let d = diff(&old, &new);
102        assert!(d.is_empty());
103    }
104
105    #[test]
106    fn diff_added_files() {
107        let old = Manifest::new();
108        let mut new = Manifest::new();
109        new.apply_write("/a.txt".to_string(), BlobHash::from_hex("aaa"), 10, None, 1);
110
111        let d = diff(&old, &new);
112        assert_eq!(d.len(), 1);
113        assert!(matches!(&d[0], DiffEntry::Added { path, .. } if path == "/a.txt"));
114    }
115
116    #[test]
117    fn diff_removed_files() {
118        let mut old = Manifest::new();
119        old.apply_write("/a.txt".to_string(), BlobHash::from_hex("aaa"), 10, None, 1);
120        let new = Manifest::new();
121
122        let d = diff(&old, &new);
123        assert_eq!(d.len(), 1);
124        assert!(matches!(&d[0], DiffEntry::Removed { path, .. } if path == "/a.txt"));
125    }
126
127    #[test]
128    fn diff_modified_files() {
129        let mut old = Manifest::new();
130        old.apply_write("/a.txt".to_string(), BlobHash::from_hex("aaa"), 10, None, 1);
131        let mut new = Manifest::new();
132        new.apply_write("/a.txt".to_string(), BlobHash::from_hex("bbb"), 20, None, 2);
133
134        let d = diff(&old, &new);
135        assert_eq!(d.len(), 1);
136        assert!(matches!(&d[0], DiffEntry::Modified { path, .. } if path == "/a.txt"));
137    }
138
139    #[test]
140    fn diff_unchanged_files_not_reported() {
141        let mut old = Manifest::new();
142        old.apply_write("/a.txt".to_string(), BlobHash::from_hex("aaa"), 10, None, 1);
143        let mut new = Manifest::new();
144        new.apply_write("/a.txt".to_string(), BlobHash::from_hex("aaa"), 10, None, 2);
145
146        let d = diff(&old, &new);
147        assert!(d.is_empty());
148    }
149
150    #[test]
151    fn diff_mixed_changes() {
152        let mut old = Manifest::new();
153        old.apply_write(
154            "/keep.txt".to_string(),
155            BlobHash::from_hex("aaa"),
156            1,
157            None,
158            1,
159        );
160        old.apply_write(
161            "/modify.txt".to_string(),
162            BlobHash::from_hex("bbb"),
163            2,
164            None,
165            1,
166        );
167        old.apply_write(
168            "/remove.txt".to_string(),
169            BlobHash::from_hex("ccc"),
170            3,
171            None,
172            1,
173        );
174
175        let mut new = Manifest::new();
176        new.apply_write(
177            "/keep.txt".to_string(),
178            BlobHash::from_hex("aaa"),
179            1,
180            None,
181            2,
182        );
183        new.apply_write(
184            "/modify.txt".to_string(),
185            BlobHash::from_hex("ddd"),
186            4,
187            None,
188            2,
189        );
190        new.apply_write(
191            "/add.txt".to_string(),
192            BlobHash::from_hex("eee"),
193            5,
194            None,
195            2,
196        );
197
198        let d = diff(&old, &new);
199        // /add.txt added, /modify.txt modified, /remove.txt removed
200        assert_eq!(d.len(), 3);
201
202        let added = d
203            .iter()
204            .filter(|e| matches!(e, DiffEntry::Added { .. }))
205            .count();
206        let removed = d
207            .iter()
208            .filter(|e| matches!(e, DiffEntry::Removed { .. }))
209            .count();
210        let modified = d
211            .iter()
212            .filter(|e| matches!(e, DiffEntry::Modified { .. }))
213            .count();
214
215        assert_eq!(added, 1);
216        assert_eq!(removed, 1);
217        assert_eq!(modified, 1);
218    }
219}