Skip to main content

stt_core/
directory.rs

1//! STT v5 directory — a compact, range-request-friendly tile index.
2//!
3//! Replaces the v2/v3 Arrow-IPC index (fixed-width columns + IPC framing) with
4//! a columnar binary encoding inspired by PMTiles v3:
5//!
6//! - **Columnar + delta + zig-zag varints.** Entries are sorted by
7//!   `(zoom, hilbert, time_start)`, so each column (zoom, hilbert, x, y,
8//!   time_start) is near-monotonic and delta-codes to ~1 byte per entry.
9//! - **Blob-run RLE.** Consecutive entries that point at the *same physical
10//!   blob* (a spatial cell whose content is identical across consecutive time
11//!   buckets — the temporal analogue of PMTiles' ocean tiles) collapse into one
12//!   run. The heavy per-blob columns (offset/length/uncompressed/crc) are then
13//!   stored once per *run* instead of once per *entry*.
14//! - **Per-run pack id (v5).** Each run carries a `pack_id` (which packed-format
15//!   object holds the blob), delta+zig-zag coded against the previous run's
16//!   pack id — packs are near-monotonic in directory order, so ~1 byte/run. A
17//!   single-file archive has `pack_id == 0` on every run.
18//! - **Pack-relative offset contiguity sentinel.** A run whose blob immediately
19//!   follows the previous run's blob *in the same pack* stores offset `0`;
20//!   otherwise a `1` flag + the raw offset. The contiguity expectation resets to
21//!   `0` whenever the pack id changes between consecutive runs, so the first run
22//!   of every pack still hits the cheap `0` sentinel. Sequential archives (the
23//!   common case) cost ~1 byte for the whole offset column.
24//!
25//! The directory is self-describing (leading version byte + entry/run counts)
26//! and decodes to exactly the `TileEntry` list that was encoded, provided the
27//! input was already (or is internally re-)sorted into directory order.
28//!
29//! This module is pure (no I/O): `encode_directory` / `decode_directory` map
30//! `&[TileEntry] ⇆ Vec<u8>`. The archive writer/reader own where the buffer
31//! lives in the file (single-file v4) or object (packed `index/<hash>.sttd`).
32//!
33//! ## v5 wire format (per-run columns, in order)
34//!
35//! Each run, in directory order, writes:
36//! 1. `run_len`         — uvarint
37//! 2. `Δpack_id`        — ivarint (zig-zag of `pack_id - prev_pack_id`)
38//! 3. offset sentinel   — uvarint `0` (== `expected_offset`) **or** `1` then a
39//!    uvarint raw offset. `expected_offset` is reset to `0` before this run when
40//!    `pack_id != prev_pack_id`.
41//! 4. `length`          — uvarint
42//! 5. `uncompressed`    — uvarint
43//! 6. `crc32c`          — 4 raw little-endian bytes
44//!
45//! The `Δpack_id` column is **new in v5** and sits immediately after `run_len`,
46//! before the offset sentinel. Per-entry key columns and the trailing
47//! `COVER_SECTION_TMIN` section are byte-identical to v4.
48
49use crate::error::{Error, Result};
50use crate::tile::TileId;
51
52/// One directory entry: where a tile lives and what it covers.
53#[derive(Debug, Clone, PartialEq)]
54pub struct TileEntry {
55    /// Zoom level.
56    pub zoom: u8,
57    /// Tile X coordinate.
58    pub x: u32,
59    /// Tile Y coordinate.
60    pub y: u32,
61    /// Inclusive temporal start (Unix ms).
62    pub time_start: i64,
63    /// Inclusive temporal end (Unix ms).
64    pub time_end: i64,
65    /// Index of the pack object holding this tile's blob (packed format).
66    ///
67    /// This selects `manifest.packs[pack_id]` and [`offset`](Self::offset) is
68    /// **pack-relative**. A per-blob (per-run) property, not a per-entry key —
69    /// entries RLE-collapse only within one pack.
70    pub pack_id: u32,
71    /// Byte offset of the compressed blob, relative to `packs[pack_id]`.
72    pub offset: u64,
73    /// Compressed blob length in bytes.
74    pub length: u32,
75    /// Uncompressed payload length in bytes.
76    pub uncompressed_size: u32,
77    /// Total feature count across the tile's layers.
78    pub feature_count: u32,
79    /// Hilbert index of `(zoom, x, y)` — directory sort key.
80    pub hilbert: u64,
81    /// CRC32C integrity tag of the compressed blob.
82    pub crc32c: u32,
83    /// Temporal bucket size in milliseconds this tile occupies.
84    ///
85    /// Base tiles record the archive's base bucket size; temporal-LOD tiles
86    /// record their coarser bucket. `None` when the archive carries no LOD
87    /// pyramid (readers fall back to `Metadata::temporal_bucket_ms`).
88    pub temporal_bucket_ms: Option<u64>,
89    /// **Tight lower covering bound** — the minimum feature *start* time actually
90    /// present in the tile. `time_start` is the addressable *bucket boundary*
91    /// (kept loose so `(z,x,y,bucket)` lookup works); `cover_t_min` is the real
92    /// earliest data, which a client uses to prune a tile whose features all lie
93    /// after a query window (`time_end` already gives the tight upper bound).
94    /// `None` when not computed (pre-covering builds), in which case clients
95    /// fall back to `time_start`.
96    pub cover_t_min: Option<i64>,
97}
98
99impl TileEntry {
100    /// The tile's identity.
101    pub fn tile_id(&self) -> TileId {
102        TileId::new(self.zoom, self.x, self.y, self.time_start.max(0) as u64)
103    }
104}
105
106/// Directory format tag (first byte of the buffer). Bumped independently of the
107/// archive `FORMAT_VERSION` so the directory codec can evolve on its own.
108///
109/// v5 adds the per-run `pack_id` column and makes the offset contiguity sentinel
110/// pack-relative (reset on every pack change). See the module docs.
111pub const DIRECTORY_VERSION: u8 = 5;
112
113/// Tag for the optional trailing **covering** section: one signed varint per
114/// entry (in directory order) giving `cover_t_min - time_start`, the tight
115/// lower temporal bound. Backward-compatible — a pre-covering archive's buffer
116/// simply ends after the per-run blob columns, and the decoder leaves
117/// `cover_t_min = None`. Forward-compatible — a decoder that doesn't recognise a
118/// trailing tag stops reading it. See [`TileEntry::cover_t_min`].
119const COVER_SECTION_TMIN: u8 = 1;
120
121// ----------------------------------------------------------------------------
122// LEB128 varints
123// ----------------------------------------------------------------------------
124
125fn put_uvarint(buf: &mut Vec<u8>, mut v: u64) {
126    loop {
127        let byte = (v & 0x7f) as u8;
128        v >>= 7;
129        if v != 0 {
130            buf.push(byte | 0x80);
131        } else {
132            buf.push(byte);
133            break;
134        }
135    }
136}
137
138fn get_uvarint(buf: &[u8], pos: &mut usize) -> Result<u64> {
139    let mut result = 0u64;
140    let mut shift = 0u32;
141    loop {
142        let byte = *buf
143            .get(*pos)
144            .ok_or_else(|| Error::InvalidArchive("directory: truncated varint".into()))?;
145        *pos += 1;
146        result |= ((byte & 0x7f) as u64) << shift;
147        if byte & 0x80 == 0 {
148            break;
149        }
150        shift += 7;
151        if shift >= 64 {
152            return Err(Error::InvalidArchive("directory: varint exceeds 64 bits".into()));
153        }
154    }
155    Ok(result)
156}
157
158#[inline]
159fn zigzag(v: i64) -> u64 {
160    ((v << 1) ^ (v >> 63)) as u64
161}
162
163#[inline]
164fn unzigzag(v: u64) -> i64 {
165    ((v >> 1) as i64) ^ -((v & 1) as i64)
166}
167
168fn put_ivarint(buf: &mut Vec<u8>, v: i64) {
169    put_uvarint(buf, zigzag(v));
170}
171
172fn get_ivarint(buf: &[u8], pos: &mut usize) -> Result<i64> {
173    Ok(unzigzag(get_uvarint(buf, pos)?))
174}
175
176// ----------------------------------------------------------------------------
177// Encode
178// ----------------------------------------------------------------------------
179
180/// Encode tile entries into the v5 directory buffer.
181///
182/// Entries are sorted into directory order `(zoom, hilbert, time_start)` first,
183/// so the caller need not pre-sort. Two entries are considered to share a blob
184/// (and so RLE-collapse) when their `(pack_id, offset, length,
185/// uncompressed_size, crc32c)` all match — which is exactly what the
186/// dedup-on-write path produces for byte-identical tiles within one pack. The
187/// `pack_id` is part of the run identity (v5): two entries collapse only when
188/// they live in the same pack *and* point at the same blob.
189pub fn encode_directory(entries: &[TileEntry]) -> Vec<u8> {
190    let mut sorted: Vec<&TileEntry> = entries.iter().collect();
191    sorted.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
192    let n = sorted.len();
193
194    // Compute blob runs up front so we can write the run count into the header.
195    // A run is a maximal stretch of consecutive entries pointing at one blob in
196    // one pack (pack_id is part of the run identity in v5).
197    let mut runs: Vec<(usize, u32, u64, u32, u32, u32)> = Vec::new();
198    let mut i = 0;
199    while i < n {
200        let head = sorted[i];
201        let crc = head.crc32c;
202        let mut j = i + 1;
203        while j < n {
204            let e = sorted[j];
205            if e.pack_id == head.pack_id
206                && e.offset == head.offset
207                && e.length == head.length
208                && e.uncompressed_size == head.uncompressed_size
209                && e.crc32c == crc
210            {
211                j += 1;
212            } else {
213                break;
214            }
215        }
216        runs.push((
217            j - i,
218            head.pack_id,
219            head.offset,
220            head.length,
221            head.uncompressed_size,
222            crc,
223        ));
224        i = j;
225    }
226
227    let mut buf = Vec::with_capacity(n * 8 + runs.len() * 8 + 16);
228    buf.push(DIRECTORY_VERSION);
229    put_uvarint(&mut buf, n as u64);
230    put_uvarint(&mut buf, runs.len() as u64);
231
232    // Per-entry key columns (delta / zig-zag coded against the previous entry).
233    let mut prev_zoom = 0i64;
234    let mut prev_hilbert = 0i64;
235    let mut prev_x = 0i64;
236    let mut prev_y = 0i64;
237    let mut prev_t = 0i64;
238    for e in &sorted {
239        put_ivarint(&mut buf, (e.zoom as i64).wrapping_sub(prev_zoom));
240        prev_zoom = e.zoom as i64;
241        put_ivarint(&mut buf, (e.hilbert as i64).wrapping_sub(prev_hilbert));
242        prev_hilbert = e.hilbert as i64;
243        put_ivarint(&mut buf, (e.x as i64).wrapping_sub(prev_x));
244        prev_x = e.x as i64;
245        put_ivarint(&mut buf, (e.y as i64).wrapping_sub(prev_y));
246        prev_y = e.y as i64;
247        put_ivarint(&mut buf, e.time_start.wrapping_sub(prev_t));
248        prev_t = e.time_start;
249        // duration may legitimately be 0; store signed so end<start round-trips too.
250        put_ivarint(&mut buf, e.time_end.wrapping_sub(e.time_start));
251        put_uvarint(&mut buf, e.feature_count as u64);
252        // temporal_bucket_ms: a presence flag (0 = None, 1 = Some) followed by
253        // the raw value when present — so every u64 (incl. u64::MAX) round-trips
254        // without colliding with the None sentinel.
255        match e.temporal_bucket_ms {
256            Some(v) => {
257                put_uvarint(&mut buf, 1);
258                put_uvarint(&mut buf, v);
259            }
260            None => put_uvarint(&mut buf, 0),
261        }
262    }
263
264    // Per-run blob columns: run_len, Δpack_id, offset (pack-relative
265    // contiguity), length, uncompressed, crc. The pack_id is delta+zig-zag coded
266    // against the previous run (packs are near-monotonic → ~1 byte/run), and the
267    // offset contiguity expectation resets to 0 whenever the pack changes so the
268    // first run of each pack hits the cheap `0` sentinel.
269    let mut expected_offset = 0u64;
270    let mut prev_pack_id = 0i64;
271    for (run_len, pack_id, offset, length, uncompressed, crc) in &runs {
272        put_uvarint(&mut buf, *run_len as u64);
273        // Δpack_id (zig-zag). When the pack changes, reset the offset contiguity
274        // expectation so this run's first blob is "contiguous from 0".
275        let pid = *pack_id as i64;
276        if pid != prev_pack_id {
277            expected_offset = 0;
278        }
279        put_ivarint(&mut buf, pid.wrapping_sub(prev_pack_id));
280        prev_pack_id = pid;
281        // Offset: 0 = contiguous (== expected); else a `1` flag followed by the
282        // raw offset, so a real u64::MAX offset can't collide with the
283        // contiguity sentinel.
284        if *offset == expected_offset {
285            put_uvarint(&mut buf, 0);
286        } else {
287            put_uvarint(&mut buf, 1);
288            put_uvarint(&mut buf, *offset);
289        }
290        put_uvarint(&mut buf, *length as u64);
291        put_uvarint(&mut buf, *uncompressed as u64);
292        buf.extend_from_slice(&crc.to_le_bytes());
293        expected_offset = offset.wrapping_add(*length as u64);
294    }
295
296    // Optional trailing covering section. Emitted only when EVERY entry carries
297    // a tight lower bound, so it's exactly N signed varints indexable 1:1 with
298    // the key rows. A mixed or all-`None` corpus writes nothing (the common case
299    // for repacked/transcoded archives), keeping the buffer byte-identical to a
300    // pre-covering directory.
301    if n > 0 && sorted.iter().all(|e| e.cover_t_min.is_some()) {
302        buf.push(COVER_SECTION_TMIN);
303        for e in &sorted {
304            // cover_t_min - time_start; signed because a feature can start
305            // before its bucket boundary. Small magnitude → ~1-2 bytes.
306            let delta = e.cover_t_min.unwrap().wrapping_sub(e.time_start);
307            put_ivarint(&mut buf, delta);
308        }
309    }
310
311    buf
312}
313
314// ----------------------------------------------------------------------------
315// Decode
316// ----------------------------------------------------------------------------
317
318/// Lowest directory version this decoder accepts. v4 (single-file archives) has
319/// no per-run `pack_id` column and whole-file offsets — decoded as `pack_id = 0`
320/// with no pack-relative reset. v5 adds the `pack_id` column. The encoder always
321/// writes [`DIRECTORY_VERSION`] (v5); v4 read support keeps the v4 single-file
322/// `ArchiveReader` working as the transcode input.
323const MIN_DIRECTORY_VERSION: u8 = 4;
324
325/// Decode a v4/v5 directory buffer back into tile entries (in directory order).
326///
327/// Both versions share the per-entry key columns and the trailing cover section.
328/// v5 prepends a `Δpack_id` (zig-zag) varint to each run's columns and resets
329/// the offset contiguity expectation on every pack change; v4 omits the column
330/// and never resets (one implicit pack, `pack_id = 0`).
331pub fn decode_directory(bytes: &[u8]) -> Result<Vec<TileEntry>> {
332    let mut pos = 0usize;
333    let version = *bytes
334        .first()
335        .ok_or_else(|| Error::InvalidArchive("directory: empty buffer".into()))?;
336    pos += 1;
337    if !(MIN_DIRECTORY_VERSION..=DIRECTORY_VERSION).contains(&version) {
338        return Err(Error::InvalidArchive(format!(
339            "directory: unsupported version {version} (expected {MIN_DIRECTORY_VERSION}..={DIRECTORY_VERSION})"
340        )));
341    }
342    let has_pack_id = version >= 5;
343    let n = get_uvarint(bytes, &mut pos)? as usize;
344    let run_count = get_uvarint(bytes, &mut pos)? as usize;
345
346    // Decode the per-entry key columns into a scratch buffer; blob fields are
347    // filled in during the run expansion below.
348    struct Key {
349        zoom: u8,
350        hilbert: u64,
351        x: u32,
352        y: u32,
353        time_start: i64,
354        time_end: i64,
355        feature_count: u32,
356        temporal_bucket_ms: Option<u64>,
357    }
358    let mut keys: Vec<Key> = Vec::with_capacity(n);
359    let mut prev_zoom = 0i64;
360    let mut prev_hilbert = 0i64;
361    let mut prev_x = 0i64;
362    let mut prev_y = 0i64;
363    let mut prev_t = 0i64;
364    for _ in 0..n {
365        prev_zoom = prev_zoom.wrapping_add(get_ivarint(bytes, &mut pos)?);
366        prev_hilbert = prev_hilbert.wrapping_add(get_ivarint(bytes, &mut pos)?);
367        prev_x = prev_x.wrapping_add(get_ivarint(bytes, &mut pos)?);
368        prev_y = prev_y.wrapping_add(get_ivarint(bytes, &mut pos)?);
369        prev_t = prev_t.wrapping_add(get_ivarint(bytes, &mut pos)?);
370        let duration = get_ivarint(bytes, &mut pos)?;
371        let feature_count_raw = get_uvarint(bytes, &mut pos)?;
372        let temporal_bucket_ms = if get_uvarint(bytes, &mut pos)? == 0 {
373            None
374        } else {
375            Some(get_uvarint(bytes, &mut pos)?)
376        };
377        // Validate the spatial / feature columns fit their target widths, so a
378        // corrupt (or foreign mis-encoded) directory errors loudly instead of
379        // silently truncating through `as u8` / `as u32`.
380        if !(0..=u8::MAX as i64).contains(&prev_zoom) {
381            return Err(Error::InvalidArchive(format!(
382                "directory: zoom {prev_zoom} out of u8 range"
383            )));
384        }
385        if !(0..=u32::MAX as i64).contains(&prev_x) {
386            return Err(Error::InvalidArchive(format!(
387                "directory: x {prev_x} out of u32 range"
388            )));
389        }
390        if !(0..=u32::MAX as i64).contains(&prev_y) {
391            return Err(Error::InvalidArchive(format!(
392                "directory: y {prev_y} out of u32 range"
393            )));
394        }
395        if feature_count_raw > u32::MAX as u64 {
396            return Err(Error::InvalidArchive(format!(
397                "directory: feature_count {feature_count_raw} out of u32 range"
398            )));
399        }
400        keys.push(Key {
401            zoom: prev_zoom as u8,
402            hilbert: prev_hilbert as u64,
403            x: prev_x as u32,
404            y: prev_y as u32,
405            time_start: prev_t,
406            time_end: prev_t.wrapping_add(duration),
407            feature_count: feature_count_raw as u32,
408            temporal_bucket_ms,
409        });
410    }
411
412    // Expand runs over the keys, assigning each run's shared blob fields. The
413    // pack_id is delta+zig-zag against the previous run, and the offset
414    // contiguity expectation resets to 0 on every pack change — symmetric with
415    // the encoder.
416    let mut entries = Vec::with_capacity(n);
417    let mut cursor = 0usize;
418    let mut expected_offset = 0u64;
419    let mut prev_pack_id = 0i64;
420    for _ in 0..run_count {
421        let run_len = get_uvarint(bytes, &mut pos)? as usize;
422        // v5 carries a Δpack_id column (zig-zag) and resets the offset
423        // contiguity expectation on every pack change. v4 has neither: one
424        // implicit pack (pack_id = 0), whole-file-contiguous offsets.
425        let pid = if has_pack_id {
426            prev_pack_id.wrapping_add(get_ivarint(bytes, &mut pos)?)
427        } else {
428            0
429        };
430        if pid != prev_pack_id {
431            expected_offset = 0;
432        }
433        prev_pack_id = pid;
434        if !(0..=u32::MAX as i64).contains(&pid) {
435            return Err(Error::InvalidArchive(format!(
436                "directory: pack_id {pid} out of u32 range"
437            )));
438        }
439        let pack_id = pid as u32;
440        let offset = if get_uvarint(bytes, &mut pos)? == 0 {
441            expected_offset
442        } else {
443            get_uvarint(bytes, &mut pos)?
444        };
445        let length = get_uvarint(bytes, &mut pos)? as u32;
446        let uncompressed_size = get_uvarint(bytes, &mut pos)? as u32;
447        let crc = u32::from_le_bytes(
448            bytes
449                .get(pos..pos + 4)
450                .ok_or_else(|| Error::InvalidArchive("directory: truncated crc".into()))?
451                .try_into()
452                .unwrap(),
453        );
454        pos += 4;
455
456        if cursor + run_len > n {
457            return Err(Error::InvalidArchive(
458                "directory: run length exceeds entry count".into(),
459            ));
460        }
461        for _ in 0..run_len {
462            let k = &keys[cursor];
463            cursor += 1;
464            entries.push(TileEntry {
465                zoom: k.zoom,
466                x: k.x,
467                y: k.y,
468                time_start: k.time_start,
469                time_end: k.time_end,
470                pack_id,
471                offset,
472                length,
473                uncompressed_size,
474                feature_count: k.feature_count,
475                hilbert: k.hilbert,
476                crc32c: crc,
477                temporal_bucket_ms: k.temporal_bucket_ms,
478                cover_t_min: None,
479            });
480        }
481        expected_offset = offset.wrapping_add(length as u64);
482    }
483
484    if cursor != n {
485        return Err(Error::InvalidArchive(format!(
486            "directory: runs covered {cursor} entries, expected {n}"
487        )));
488    }
489
490    // Optional trailing covering section(s). A pre-covering archive's buffer
491    // ends here; if bytes remain, read tagged sections. Unknown tags stop the
492    // scan (forward-compat) rather than erroring.
493    if pos < bytes.len() {
494        let tag = bytes[pos];
495        pos += 1;
496        if tag == COVER_SECTION_TMIN {
497            for e in entries.iter_mut() {
498                let delta = get_ivarint(bytes, &mut pos)?;
499                e.cover_t_min = Some(e.time_start.wrapping_add(delta));
500            }
501        }
502    }
503
504    Ok(entries)
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn entry(
512        zoom: u8,
513        x: u32,
514        y: u32,
515        hilbert: u64,
516        ts: i64,
517        te: i64,
518        offset: u64,
519        length: u32,
520        unc: u32,
521        fc: u32,
522        crc: u32,
523        tb: Option<u64>,
524    ) -> TileEntry {
525        TileEntry {
526            zoom,
527            x,
528            y,
529            time_start: ts,
530            time_end: te,
531            pack_id: 0,
532            offset,
533            length,
534            uncompressed_size: unc,
535            feature_count: fc,
536            hilbert,
537            crc32c: crc,
538            temporal_bucket_ms: tb,
539            cover_t_min: None,
540        }
541    }
542
543    /// Build an entry with an explicit `pack_id` (v5 packed format).
544    #[allow(clippy::too_many_arguments)]
545    fn entry_pack(
546        pack_id: u32,
547        zoom: u8,
548        x: u32,
549        y: u32,
550        hilbert: u64,
551        ts: i64,
552        te: i64,
553        offset: u64,
554        length: u32,
555        unc: u32,
556        fc: u32,
557        crc: u32,
558        tb: Option<u64>,
559    ) -> TileEntry {
560        let mut e = entry(zoom, x, y, hilbert, ts, te, offset, length, unc, fc, crc, tb);
561        e.pack_id = pack_id;
562        e
563    }
564
565    #[test]
566    fn empty_roundtrips() {
567        let bytes = encode_directory(&[]);
568        let back = decode_directory(&bytes).unwrap();
569        assert!(back.is_empty());
570    }
571
572    /// The optional covering section round-trips `cover_t_min` exactly (incl. a
573    /// value BELOW `time_start` — a feature starting before its bucket edge).
574    #[test]
575    fn cover_t_min_section_roundtrips() {
576        let mut entries = Vec::new();
577        for i in 0..20u32 {
578            let mut e = entry(
579                10, i, i, i as u64, (i as i64) * 1000, (i as i64) * 1000 + 900,
580                64 + i as u64 * 50, 50, 100, i, 0x100 + i, Some(1000),
581            );
582            // tight lower bound: usually inside the bucket, but entry 0 starts
583            // 500ms BEFORE its bucket boundary to exercise the signed delta.
584            e.cover_t_min = Some(if i == 0 { -500 } else { (i as i64) * 1000 + 250 });
585            entries.push(e);
586        }
587        let bytes = encode_directory(&entries);
588        let back = decode_directory(&bytes).unwrap();
589        let mut expected = entries.clone();
590        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
591        assert_eq!(back, expected);
592        assert!(back.iter().all(|e| e.cover_t_min.is_some()));
593    }
594
595    /// A directory with NO covering bounds must produce a buffer byte-identical
596    /// to the pre-covering codec (no trailing section), and decode to `None`.
597    #[test]
598    fn absent_cover_section_is_backward_compatible() {
599        let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
600        let bytes = encode_directory(std::slice::from_ref(&e));
601        // No trailing tag byte: the last byte is the run's crc (4 LE bytes),
602        // never a lone COVER_SECTION_TMIN tag.
603        let back = decode_directory(&bytes).unwrap();
604        assert_eq!(back.len(), 1);
605        assert_eq!(back[0].cover_t_min, None);
606    }
607
608    #[test]
609    fn single_entry_roundtrips() {
610        let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 0xDEAD_BEEF, Some(3_600_000));
611        let bytes = encode_directory(std::slice::from_ref(&e));
612        let back = decode_directory(&bytes).unwrap();
613        assert_eq!(back, vec![e]);
614    }
615
616    /// Distinct contiguous blobs: the offset column should ride the contiguity
617    /// sentinel, and every field must round-trip exactly.
618    #[test]
619    fn contiguous_distinct_blobs_roundtrip() {
620        let mut entries = Vec::new();
621        let mut offset = 64u64;
622        for i in 0..50u32 {
623            let len = 100 + i;
624            entries.push(entry(
625                12,
626                i,
627                i,
628                i as u64, // hilbert monotonic → already in directory order
629                (i as i64) * 1000,
630                (i as i64) * 1000 + 500,
631                offset,
632                len,
633                len * 2,
634                i,
635                0x1000 + i,
636                None,
637            ));
638            offset += len as u64;
639        }
640        let bytes = encode_directory(&entries);
641        let back = decode_directory(&bytes).unwrap();
642        assert_eq!(back, entries);
643    }
644
645    /// Temporal RLE: one spatial cell whose content is identical across many
646    /// time buckets (same blob → same offset/length/crc). The encoding must
647    /// collapse these into a single run yet decode back to every entry.
648    #[test]
649    fn identical_across_time_collapses_to_one_run_and_roundtrips() {
650        let crc = 0xABCD_1234u32;
651        let mut entries = Vec::new();
652        // 100 consecutive hourly buckets of one static cell, all the same blob.
653        for b in 0..100u64 {
654            entries.push(entry(
655                9,
656                3,
657                4,
658                77, // same hilbert (same cell)
659                (b as i64) * 3_600_000,
660                (b as i64) * 3_600_000 + 3_599_999,
661                4096,   // same offset (deduped blob)
662                512,    // same length
663                1024,   // same uncompressed
664                64,
665                crc,    // same crc
666                Some(3_600_000),
667            ));
668        }
669        let bytes = encode_directory(&entries);
670        let back = decode_directory(&bytes).unwrap();
671        assert_eq!(back, entries);
672
673        // The headline RLE win is on the per-blob columns (offset/length/
674        // uncompressed/crc): the static run stores them once instead of 100×.
675        // Compare against the same corpus with *distinct* blobs, where every
676        // entry needs its own run.
677        let mut distinct = entries.clone();
678        for (i, e) in distinct.iter_mut().enumerate() {
679            e.offset = 4096 + i as u64 * 512;
680            e.crc32c = 0x9000 + i as u32;
681        }
682        let distinct_bytes = encode_directory(&distinct);
683        eprintln!(
684            "static-cell RLE directory: {} bytes vs distinct-blob: {} bytes",
685            bytes.len(),
686            distinct_bytes.len()
687        );
688        // The blob columns are ~10 bytes/run. Collapsing 100 runs → 1 must save
689        // close to the full 99 × 10 bytes the distinct encoding spends on them.
690        assert!(
691            distinct_bytes.len() >= bytes.len() + 99 * 8,
692            "RLE should reclaim the per-blob columns: rle={}, distinct={}",
693            bytes.len(),
694            distinct_bytes.len()
695        );
696    }
697
698    /// A mixed corpus across several zooms, cells, times, with some shared
699    /// blobs and some unsorted input — the codec must sort, RLE, and round-trip.
700    #[test]
701    fn mixed_unsorted_corpus_roundtrips() {
702        let mut entries = Vec::new();
703        let mut off = 64u64;
704        for zoom in [4u8, 8, 12] {
705            for cell in 0..20u64 {
706                for t in 0..5i64 {
707                    let len = 80 + (cell as u32 % 7);
708                    // Every 3rd (cell,t) reuses the previous blob to exercise RLE.
709                    let shared = t > 0 && t % 3 != 0;
710                    let (offset, crc) = if shared {
711                        (off, 0x5555)
712                    } else {
713                        off += len as u64;
714                        (off, 0x6000 + cell as u32 + t as u32)
715                    };
716                    entries.push(entry(
717                        zoom,
718                        cell as u32,
719                        (cell * 2) as u32,
720                        cell * 10 + zoom as u64, // arbitrary but stable hilbert
721                        t * 1000 + cell as i64,
722                        t * 1000 + cell as i64 + 250,
723                        offset,
724                        len,
725                        len * 3,
726                        (cell + t as u64) as u32,
727                        crc,
728                        if zoom == 4 { Some(86_400_000) } else { None },
729                    ));
730                }
731            }
732        }
733        // Shuffle deterministically so encode must sort.
734        entries.reverse();
735
736        let bytes = encode_directory(&entries);
737        let back = decode_directory(&bytes).unwrap();
738
739        // Expected = the same entries in directory order.
740        let mut expected = entries.clone();
741        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
742        assert_eq!(back, expected);
743    }
744
745    #[test]
746    fn negative_and_extreme_times_roundtrip() {
747        let entries = vec![
748            entry(0, 0, 0, 0, i64::MIN + 1, i64::MIN + 10, 64, 8, 16, 1, 1, None),
749            entry(0, 0, 0, 0, -5000, -1000, 72, 8, 16, 1, 2, Some(1)),
750            entry(0, 0, 0, 0, 0, 0, 80, 8, 16, 1, 3, None),
751            entry(0, 0, 0, 0, i64::MAX - 10, i64::MAX, 88, 8, 16, 1, 4, None),
752        ];
753        let bytes = encode_directory(&entries);
754        let back = decode_directory(&bytes).unwrap();
755        assert_eq!(back, entries);
756    }
757
758    #[test]
759    fn truncated_buffer_errors() {
760        let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
761        let bytes = encode_directory(std::slice::from_ref(&e));
762        // Lop off the trailing crc bytes.
763        let truncated = &bytes[..bytes.len() - 2];
764        assert!(decode_directory(truncated).is_err());
765    }
766
767    #[test]
768    fn wrong_version_errors() {
769        let mut bytes = encode_directory(&[]);
770        bytes[0] = 99;
771        assert!(decode_directory(&bytes).is_err());
772    }
773
774    /// A foreign / corrupt directory whose zoom delta overflows u8 must error
775    /// rather than silently truncate via `as u8`.
776    #[test]
777    fn decode_rejects_out_of_range_columns() {
778        let mut buf = Vec::new();
779        buf.push(DIRECTORY_VERSION);
780        put_uvarint(&mut buf, 1); // N
781        put_uvarint(&mut buf, 1); // R
782        put_ivarint(&mut buf, 300); // Δzoom (out of u8 range)
783        put_ivarint(&mut buf, 0); // Δhilbert
784        put_ivarint(&mut buf, 0); // Δx
785        put_ivarint(&mut buf, 0); // Δy
786        put_ivarint(&mut buf, 0); // Δtime_start
787        put_ivarint(&mut buf, 0); // duration
788        put_uvarint(&mut buf, 0); // feature_count
789        put_uvarint(&mut buf, 0); // bucket present = 0
790        put_uvarint(&mut buf, 1); // run_len
791        put_ivarint(&mut buf, 0); // Δpack_id (v5)
792        put_uvarint(&mut buf, 0); // offset flag (contiguous)
793        put_uvarint(&mut buf, 0); // length
794        put_uvarint(&mut buf, 0); // uncompressed
795        buf.extend_from_slice(&0u32.to_le_bytes()); // crc
796        assert!(decode_directory(&buf).is_err());
797    }
798
799    /// Boundary: u64::MAX must round-trip for both the blob offset and the
800    /// temporal bucket — neither sentinel may collide with a real value.
801    #[test]
802    fn u64_max_offset_and_bucket_roundtrip() {
803        let entries = vec![
804            entry(5, 1, 1, 10, 0, 100, u64::MAX, 8, 16, 1, 7, Some(u64::MAX)),
805            entry(5, 2, 2, 11, 0, 100, 64, 8, 16, 1, 8, Some(3_600_000)),
806            entry(5, 3, 3, 12, 0, 100, 0, 8, 16, 1, 9, None),
807        ];
808        let bytes = encode_directory(&entries);
809        let back = decode_directory(&bytes).unwrap();
810        assert_eq!(back, entries);
811    }
812
813    /// v5: entries spread across multiple packs round-trip with the correct
814    /// `pack_id`, and each pack's offsets are pack-relative (every pack starts
815    /// from a small offset, riding the contiguity sentinel reset).
816    #[test]
817    fn multi_pack_offsets_are_pack_relative_and_roundtrip() {
818        let mut entries = Vec::new();
819        // Three packs, each with a fresh offset run starting at 0. Distinct
820        // blobs within a pack are contiguous (offset += length).
821        for pack_id in 0..3u32 {
822            let mut off = 0u64;
823            for i in 0..6u32 {
824                let hil = (pack_id as u64) * 100 + i as u64; // monotone in dir order
825                let len = 40 + i;
826                entries.push(entry_pack(
827                    pack_id,
828                    9,
829                    pack_id * 10 + i,
830                    i,
831                    hil,
832                    (i as i64) * 1000,
833                    (i as i64) * 1000 + 500,
834                    off,
835                    len,
836                    len * 2,
837                    i,
838                    0x2000 + pack_id * 100 + i,
839                    Some(3_600_000),
840                ));
841                off += len as u64;
842            }
843        }
844        let bytes = encode_directory(&entries);
845        let back = decode_directory(&bytes).unwrap();
846        let mut expected = entries.clone();
847        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
848        assert_eq!(back, expected);
849        // Every pack contributes entries, and the first run of each pack has
850        // offset 0 (pack-relative reset). pack_ids observed are exactly {0,1,2}.
851        let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
852        assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
853        for p in 0..3u32 {
854            assert!(back.iter().any(|e| e.pack_id == p && e.offset == 0));
855        }
856    }
857
858    /// v5: RLE within a pack still collapses a static cell across time, but two
859    /// blobs that are byte-identical in *different* packs must NOT collapse
860    /// (pack_id is part of the run identity), and both decode to their own pack.
861    #[test]
862    fn rle_collapses_within_pack_but_not_across_packs() {
863        let crc = 0x55AA_55AAu32;
864        let mut entries = Vec::new();
865        // Pack 0: one static cell across 50 hourly buckets — one run.
866        for b in 0..50u64 {
867            entries.push(entry_pack(
868                0, 9, 3, 4, 77,
869                (b as i64) * 3_600_000,
870                (b as i64) * 3_600_000 + 3_599_999,
871                4096, 512, 1024, 64, crc, Some(3_600_000),
872            ));
873        }
874        // Pack 1: same blob fields (offset/len/unc/crc) but a different pack —
875        // a separate physical object, so it must stay its own run.
876        for b in 0..50u64 {
877            entries.push(entry_pack(
878                1, 9, 5, 6, 99,
879                (b as i64) * 3_600_000,
880                (b as i64) * 3_600_000 + 3_599_999,
881                4096, 512, 1024, 64, crc, Some(3_600_000),
882            ));
883        }
884        let bytes = encode_directory(&entries);
885        let back = decode_directory(&bytes).unwrap();
886        let mut expected = entries.clone();
887        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
888        assert_eq!(back, expected);
889        assert!(back.iter().any(|e| e.pack_id == 0));
890        assert!(back.iter().any(|e| e.pack_id == 1));
891    }
892
893    /// v5: the cover section still round-trips alongside multi-pack pack_ids.
894    #[test]
895    fn cover_section_roundtrips_with_pack_ids() {
896        let mut entries = Vec::new();
897        for i in 0..12u32 {
898            let pack_id = i / 4; // packs 0,1,2
899            let mut e = entry_pack(
900                pack_id, 10, i, i, i as u64,
901                (i as i64) * 1000, (i as i64) * 1000 + 900,
902                (i % 4) as u64 * 50, 50, 100, i, 0x300 + i, Some(1000),
903            );
904            e.cover_t_min = Some((i as i64) * 1000 + 250);
905            entries.push(e);
906        }
907        let bytes = encode_directory(&entries);
908        let back = decode_directory(&bytes).unwrap();
909        let mut expected = entries.clone();
910        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
911        assert_eq!(back, expected);
912        assert!(back.iter().all(|e| e.cover_t_min.is_some()));
913        let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
914        assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
915    }
916}