Skip to main content

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}