oxigdal_pmtiles/diff.rs
1//! Tile-set diff between two PMTiles archives.
2//!
3//! Compares two in-memory PMTiles v3 archives and reports per-tile
4//! changes — tiles added, removed, or content-changed — using a 64-bit
5//! [FNV-1a](https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function)
6//! non-cryptographic hash for cheap content comparison.
7//!
8//! The diff is purely structural: tiles are identified by their PMTiles v3
9//! Hilbert-curve tile ID, and content-changed tiles are detected by
10//! comparing their decompressed-as-stored (raw) byte payload's FNV-1a hash.
11//! Two tiles with identical bytes are treated as unchanged even when the
12//! source archives differ in unrelated layout (offsets, dedup runs, …).
13//!
14//! # Output stability
15//!
16//! Each `Vec` field of [`DiffReport`] is sorted by ascending `tile_id` so
17//! that the output is deterministic and suitable for direct comparison
18//! across runs.
19//!
20//! # Example
21//!
22//! ```no_run
23//! use oxigdal_pmtiles::{PmTilesBuilder, TileType, diff_archives};
24//!
25//! let mut old = PmTilesBuilder::new(TileType::Png, 0, 0);
26//! old.add_tile(0, 0, 0, b"v1").unwrap();
27//! let old_bytes = old.build().unwrap();
28//!
29//! let mut new = PmTilesBuilder::new(TileType::Png, 0, 0);
30//! new.add_tile(0, 0, 0, b"v2").unwrap();
31//! let new_bytes = new.build().unwrap();
32//!
33//! let report = diff_archives(&old_bytes, &new_bytes).unwrap();
34//! assert_eq!(report.added.len(), 0);
35//! assert_eq!(report.removed.len(), 0);
36//! assert_eq!(report.changed.len(), 1);
37//! ```
38
39use std::collections::HashMap;
40
41use crate::error::PmTilesError;
42use crate::pmtiles::PmTilesReader;
43
44// ---------------------------------------------------------------------------
45// Public types
46// ---------------------------------------------------------------------------
47
48/// A single per-tile change reported by [`diff_archives`].
49///
50/// Every variant carries the PMTiles v3 `tile_id` along with the resolved
51/// `(z, x, y)` coordinates and the relevant byte size(s). This is enough to
52/// drive most downstream tooling (CDN purge lists, sync utilities, audit
53/// logs) without requiring access to the original archives.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum TileChange {
56 /// Tile present in the new archive but absent in the old archive.
57 Added {
58 /// PMTiles v3 Hilbert-curve tile ID.
59 tile_id: u64,
60 /// Zoom level resolved from the tile ID.
61 z: u8,
62 /// Tile column resolved from the tile ID.
63 x: u32,
64 /// Tile row resolved from the tile ID.
65 y: u32,
66 /// Byte length of the tile payload in the new archive.
67 new_bytes: usize,
68 },
69 /// Tile present in the old archive but absent in the new archive.
70 Removed {
71 /// PMTiles v3 Hilbert-curve tile ID.
72 tile_id: u64,
73 /// Zoom level resolved from the tile ID.
74 z: u8,
75 /// Tile column resolved from the tile ID.
76 x: u32,
77 /// Tile row resolved from the tile ID.
78 y: u32,
79 /// Byte length of the tile payload in the old archive.
80 old_bytes: usize,
81 },
82 /// Tile present in both archives with different content.
83 Changed {
84 /// PMTiles v3 Hilbert-curve tile ID.
85 tile_id: u64,
86 /// Zoom level resolved from the tile ID.
87 z: u8,
88 /// Tile column resolved from the tile ID.
89 x: u32,
90 /// Tile row resolved from the tile ID.
91 y: u32,
92 /// Byte length of the tile payload in the old archive.
93 old_bytes: usize,
94 /// Byte length of the tile payload in the new archive.
95 new_bytes: usize,
96 },
97}
98
99impl TileChange {
100 /// PMTiles v3 tile ID of the changed tile, regardless of variant.
101 pub fn tile_id(&self) -> u64 {
102 match self {
103 Self::Added { tile_id, .. }
104 | Self::Removed { tile_id, .. }
105 | Self::Changed { tile_id, .. } => *tile_id,
106 }
107 }
108
109 /// Resolved zoom level of the changed tile.
110 pub fn zoom(&self) -> u8 {
111 match self {
112 Self::Added { z, .. } | Self::Removed { z, .. } | Self::Changed { z, .. } => *z,
113 }
114 }
115
116 /// Resolved `(x, y)` tile column/row.
117 pub fn xy(&self) -> (u32, u32) {
118 match self {
119 Self::Added { x, y, .. } | Self::Removed { x, y, .. } | Self::Changed { x, y, .. } => {
120 (*x, *y)
121 }
122 }
123 }
124}
125
126/// Full per-tile diff between two PMTiles archives.
127///
128/// Each `Vec` is sorted by ascending `tile_id`. `unchanged_count` counts
129/// tiles present in both archives whose FNV-1a content hashes match.
130#[derive(Debug, Clone, Default)]
131pub struct DiffReport {
132 /// Tiles present in the new archive but absent in the old archive.
133 pub added: Vec<TileChange>,
134 /// Tiles present in the old archive but absent in the new archive.
135 pub removed: Vec<TileChange>,
136 /// Tiles present in both with differing FNV-1a content hashes.
137 pub changed: Vec<TileChange>,
138 /// Tiles present in both with identical FNV-1a content hashes.
139 pub unchanged_count: u64,
140}
141
142impl DiffReport {
143 /// Total number of changes (added + removed + changed).
144 pub fn total_changes(&self) -> usize {
145 self.added.len() + self.removed.len() + self.changed.len()
146 }
147
148 /// Sum of byte lengths of all tiles in the [`Self::added`] list.
149 pub fn total_added_bytes(&self) -> usize {
150 self.added
151 .iter()
152 .map(|c| match c {
153 TileChange::Added { new_bytes, .. } => *new_bytes,
154 _ => 0,
155 })
156 .sum()
157 }
158
159 /// Sum of byte lengths of all tiles in the [`Self::removed`] list.
160 pub fn total_removed_bytes(&self) -> usize {
161 self.removed
162 .iter()
163 .map(|c| match c {
164 TileChange::Removed { old_bytes, .. } => *old_bytes,
165 _ => 0,
166 })
167 .sum()
168 }
169
170 /// Net byte delta of changed tiles (new total minus old total).
171 ///
172 /// A positive value means changed tiles grew on average; a negative
173 /// value means they shrank.
174 pub fn changed_byte_delta(&self) -> i64 {
175 self.changed
176 .iter()
177 .map(|c| match c {
178 TileChange::Changed {
179 old_bytes,
180 new_bytes,
181 ..
182 } => *new_bytes as i64 - *old_bytes as i64,
183 _ => 0,
184 })
185 .sum()
186 }
187
188 /// Returns `true` when there are no added, removed, or changed tiles.
189 pub fn is_empty(&self) -> bool {
190 self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
191 }
192}
193
194/// Compact summary of a [`DiffReport`] (counts only).
195///
196/// Useful when callers only need a tally of changes without the per-tile
197/// detail (e.g. for periodic monitoring or alerting).
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
199pub struct DiffSummary {
200 /// Number of tiles in the new archive that are absent from the old.
201 pub added: u64,
202 /// Number of tiles in the old archive that are absent from the new.
203 pub removed: u64,
204 /// Number of tiles present in both with differing content.
205 pub changed: u64,
206 /// Number of tiles present in both with identical content.
207 pub unchanged: u64,
208}
209
210impl DiffSummary {
211 /// Total number of changes (added + removed + changed).
212 pub fn total_changes(&self) -> u64 {
213 self.added + self.removed + self.changed
214 }
215
216 /// Total number of tiles considered (changes + unchanged).
217 pub fn total_tiles(&self) -> u64 {
218 self.total_changes() + self.unchanged
219 }
220}
221
222// ---------------------------------------------------------------------------
223// FNV-1a 64-bit content hash
224// ---------------------------------------------------------------------------
225
226/// FNV-1a 64-bit hash used for cheap tile-content comparison.
227///
228/// This matches the dedup hash used by [`crate::writer::PmTilesBuilder`] so
229/// that round-tripping an archive through a writer does not falsely report
230/// tiles as changed.
231///
232/// FNV-1a is non-cryptographic; collisions are theoretically possible but
233/// astronomically unlikely for tile-sized payloads. This is acceptable for
234/// the diff use case (change detection, not security).
235fn fnv1a_64(data: &[u8]) -> u64 {
236 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
237 const FNV_PRIME: u64 = 0x0100_0000_01b3;
238 let mut hash = FNV_OFFSET;
239 for &byte in data {
240 hash ^= u64::from(byte);
241 hash = hash.wrapping_mul(FNV_PRIME);
242 }
243 hash
244}
245
246// ---------------------------------------------------------------------------
247// Index building
248// ---------------------------------------------------------------------------
249
250/// Compact per-tile record kept while building the diff index.
251#[derive(Debug, Clone, Copy)]
252struct IndexedTile {
253 z: u8,
254 x: u32,
255 y: u32,
256 content_hash: u64,
257 byte_size: usize,
258}
259
260/// Build a `tile_id → IndexedTile` map from a parsed reader.
261///
262/// Tiles whose directory entry exists but whose payload cannot be retrieved
263/// surface a [`PmTilesError::InvalidArchive`] so that diff results are not
264/// silently incorrect.
265fn build_index(reader: &PmTilesReader) -> Result<HashMap<u64, IndexedTile>, PmTilesError> {
266 let infos = reader.enumerate_tiles()?;
267 let mut map: HashMap<u64, IndexedTile> = HashMap::with_capacity(infos.len());
268
269 for info in infos {
270 let bytes = reader.get_tile(info.z, info.x, info.y)?.ok_or_else(|| {
271 PmTilesError::InvalidArchive(format!(
272 "tile_id {} (z={}, x={}, y={}) listed in directory but payload missing",
273 info.tile_id, info.z, info.x, info.y
274 ))
275 })?;
276 map.insert(
277 info.tile_id,
278 IndexedTile {
279 z: info.z,
280 x: info.x,
281 y: info.y,
282 content_hash: fnv1a_64(&bytes),
283 byte_size: bytes.len(),
284 },
285 );
286 }
287
288 Ok(map)
289}
290
291// ---------------------------------------------------------------------------
292// Public diff functions
293// ---------------------------------------------------------------------------
294
295/// Compare two PMTiles archives by raw bytes and return a full diff report.
296///
297/// Both inputs must be valid PMTiles v3 archives parsable by
298/// [`PmTilesReader::from_bytes`]. Tile content is compared using the raw
299/// (possibly compressed) payload bytes — this matches the byte stream the
300/// underlying storage actually holds and is consistent with the writer's
301/// dedup behaviour.
302///
303/// # Errors
304/// Propagates any [`PmTilesError`] from header parsing, directory decoding,
305/// or tile extraction. An [`PmTilesError::InvalidArchive`] is returned when
306/// a tile listed in the directory cannot be retrieved.
307pub fn diff_archives(old_bytes: &[u8], new_bytes: &[u8]) -> Result<DiffReport, PmTilesError> {
308 let old_reader = PmTilesReader::from_bytes(old_bytes.to_vec())?;
309 let new_reader = PmTilesReader::from_bytes(new_bytes.to_vec())?;
310
311 let old_index = build_index(&old_reader)?;
312 let new_index = build_index(&new_reader)?;
313
314 let mut report = DiffReport::default();
315
316 // Removed: tiles in old but not in new.
317 for (&tile_id, tile) in &old_index {
318 if !new_index.contains_key(&tile_id) {
319 report.removed.push(TileChange::Removed {
320 tile_id,
321 z: tile.z,
322 x: tile.x,
323 y: tile.y,
324 old_bytes: tile.byte_size,
325 });
326 }
327 }
328
329 // Added + Changed + Unchanged: iterate new and look up old.
330 for (&tile_id, new_tile) in &new_index {
331 match old_index.get(&tile_id) {
332 None => report.added.push(TileChange::Added {
333 tile_id,
334 z: new_tile.z,
335 x: new_tile.x,
336 y: new_tile.y,
337 new_bytes: new_tile.byte_size,
338 }),
339 Some(old_tile) => {
340 if old_tile.content_hash != new_tile.content_hash {
341 report.changed.push(TileChange::Changed {
342 tile_id,
343 z: new_tile.z,
344 x: new_tile.x,
345 y: new_tile.y,
346 old_bytes: old_tile.byte_size,
347 new_bytes: new_tile.byte_size,
348 });
349 } else {
350 report.unchanged_count += 1;
351 }
352 }
353 }
354 }
355
356 // Deterministic ordering for stable output and reproducible tests.
357 report.added.sort_by_key(TileChange::tile_id);
358 report.removed.sort_by_key(TileChange::tile_id);
359 report.changed.sort_by_key(TileChange::tile_id);
360
361 Ok(report)
362}
363
364/// Compare two PMTiles archives and return only the summary counts.
365///
366/// Convenience wrapper around [`diff_archives`] that drops the per-tile
367/// detail. Has the same error semantics as [`diff_archives`].
368///
369/// # Errors
370/// Propagates errors from [`diff_archives`].
371pub fn diff_archives_summary(
372 old_bytes: &[u8],
373 new_bytes: &[u8],
374) -> Result<DiffSummary, PmTilesError> {
375 let report = diff_archives(old_bytes, new_bytes)?;
376 Ok(DiffSummary {
377 added: report.added.len() as u64,
378 removed: report.removed.len() as u64,
379 changed: report.changed.len() as u64,
380 unchanged: report.unchanged_count,
381 })
382}
383
384// ---------------------------------------------------------------------------
385// Unit tests
386// ---------------------------------------------------------------------------
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_fnv1a_64_known_offset_for_empty_input() {
394 // Standard FNV-1a 64 spec: empty input → FNV_OFFSET.
395 assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
396 }
397
398 #[test]
399 fn test_fnv1a_64_distinguishes_different_inputs() {
400 assert_ne!(fnv1a_64(b"hello"), fnv1a_64(b"world"));
401 }
402
403 #[test]
404 fn test_fnv1a_64_is_deterministic() {
405 assert_eq!(fnv1a_64(b"oxigdal"), fnv1a_64(b"oxigdal"));
406 }
407
408 #[test]
409 fn test_diff_report_default_is_empty() {
410 let r = DiffReport::default();
411 assert!(r.is_empty());
412 assert_eq!(r.total_changes(), 0);
413 assert_eq!(r.total_added_bytes(), 0);
414 assert_eq!(r.total_removed_bytes(), 0);
415 assert_eq!(r.changed_byte_delta(), 0);
416 }
417
418 #[test]
419 fn test_diff_summary_aggregates_counts() {
420 let s = DiffSummary {
421 added: 1,
422 removed: 2,
423 changed: 3,
424 unchanged: 4,
425 };
426 assert_eq!(s.total_changes(), 6);
427 assert_eq!(s.total_tiles(), 10);
428 }
429
430 #[test]
431 fn test_tile_change_accessors() {
432 let added = TileChange::Added {
433 tile_id: 7,
434 z: 2,
435 x: 1,
436 y: 1,
437 new_bytes: 32,
438 };
439 assert_eq!(added.tile_id(), 7);
440 assert_eq!(added.zoom(), 2);
441 assert_eq!(added.xy(), (1, 1));
442
443 let removed = TileChange::Removed {
444 tile_id: 11,
445 z: 3,
446 x: 4,
447 y: 5,
448 old_bytes: 100,
449 };
450 assert_eq!(removed.tile_id(), 11);
451 assert_eq!(removed.zoom(), 3);
452 assert_eq!(removed.xy(), (4, 5));
453
454 let changed = TileChange::Changed {
455 tile_id: 21,
456 z: 4,
457 x: 2,
458 y: 3,
459 old_bytes: 10,
460 new_bytes: 20,
461 };
462 assert_eq!(changed.tile_id(), 21);
463 assert_eq!(changed.zoom(), 4);
464 assert_eq!(changed.xy(), (2, 3));
465 }
466}