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    // Adversarial-input guard: every entry costs ≥ 8 wire bytes (8 one-byte-
346    // minimum varints) and every run ≥ 8 (v4) / 9 (v5, with the Δpack_id
347    // column), and entries + runs share this one buffer — so
348    // 8·(n + run_count) ≤ bytes.len() must hold for any decodable input
349    // (/8 blanket keeps degenerate zero-length-run v4 buffers decodable).
350    // Rejecting BEFORE the `with_capacity` calls below keeps a doctored
351    // header from forcing a huge allocation: the scratch `Key` is 56 B/entry,
352    // so the un-divided `n > bytes.len()` form of this guard still allowed a
353    // 56× amplification (a 100 MB object claiming n = 100 M entries forced a
354    // 5.6 GB allocation). Post-division the worst case is 56·(len/8) = 7×
355    // the buffer — the same as a maximally compact legitimate directory,
356    // i.e. tight without parsing (guarded by tests/adversarial_decode.rs).
357    if n.saturating_add(run_count) > bytes.len() / 8 {
358        return Err(Error::InvalidArchive(format!(
359            "directory: header claims {n} entries + {run_count} runs, more than the \
360             {}-byte buffer can hold at ≥8 bytes per record",
361            bytes.len()
362        )));
363    }
364
365    // Decode the per-entry key columns into a scratch buffer; blob fields are
366    // filled in during the run expansion below.
367    struct Key {
368        zoom: u8,
369        hilbert: u64,
370        x: u32,
371        y: u32,
372        time_start: i64,
373        time_end: i64,
374        feature_count: u32,
375        temporal_bucket_ms: Option<u64>,
376    }
377    let mut keys: Vec<Key> = Vec::with_capacity(n);
378    let mut prev_zoom = 0i64;
379    let mut prev_hilbert = 0i64;
380    let mut prev_x = 0i64;
381    let mut prev_y = 0i64;
382    let mut prev_t = 0i64;
383    for _ in 0..n {
384        prev_zoom = prev_zoom.wrapping_add(get_ivarint(bytes, &mut pos)?);
385        prev_hilbert = prev_hilbert.wrapping_add(get_ivarint(bytes, &mut pos)?);
386        prev_x = prev_x.wrapping_add(get_ivarint(bytes, &mut pos)?);
387        prev_y = prev_y.wrapping_add(get_ivarint(bytes, &mut pos)?);
388        prev_t = prev_t.wrapping_add(get_ivarint(bytes, &mut pos)?);
389        let duration = get_ivarint(bytes, &mut pos)?;
390        let feature_count_raw = get_uvarint(bytes, &mut pos)?;
391        let temporal_bucket_ms = if get_uvarint(bytes, &mut pos)? == 0 {
392            None
393        } else {
394            Some(get_uvarint(bytes, &mut pos)?)
395        };
396        // Validate the spatial / feature columns fit their target widths, so a
397        // corrupt (or foreign mis-encoded) directory errors loudly instead of
398        // silently truncating through `as u8` / `as u32`.
399        if !(0..=u8::MAX as i64).contains(&prev_zoom) {
400            return Err(Error::InvalidArchive(format!(
401                "directory: zoom {prev_zoom} out of u8 range"
402            )));
403        }
404        if !(0..=u32::MAX as i64).contains(&prev_x) {
405            return Err(Error::InvalidArchive(format!(
406                "directory: x {prev_x} out of u32 range"
407            )));
408        }
409        if !(0..=u32::MAX as i64).contains(&prev_y) {
410            return Err(Error::InvalidArchive(format!(
411                "directory: y {prev_y} out of u32 range"
412            )));
413        }
414        if feature_count_raw > u32::MAX as u64 {
415            return Err(Error::InvalidArchive(format!(
416                "directory: feature_count {feature_count_raw} out of u32 range"
417            )));
418        }
419        keys.push(Key {
420            zoom: prev_zoom as u8,
421            hilbert: prev_hilbert as u64,
422            x: prev_x as u32,
423            y: prev_y as u32,
424            time_start: prev_t,
425            time_end: prev_t.wrapping_add(duration),
426            feature_count: feature_count_raw as u32,
427            temporal_bucket_ms,
428        });
429    }
430
431    // Expand runs over the keys, assigning each run's shared blob fields. The
432    // pack_id is delta+zig-zag against the previous run, and the offset
433    // contiguity expectation resets to 0 on every pack change — symmetric with
434    // the encoder.
435    let mut entries = Vec::with_capacity(n);
436    let mut cursor = 0usize;
437    let mut expected_offset = 0u64;
438    let mut prev_pack_id = 0i64;
439    for _ in 0..run_count {
440        let run_len = get_uvarint(bytes, &mut pos)? as usize;
441        // v5 carries a Δpack_id column (zig-zag) and resets the offset
442        // contiguity expectation on every pack change. v4 has neither: one
443        // implicit pack (pack_id = 0), whole-file-contiguous offsets.
444        let pid = if has_pack_id {
445            prev_pack_id.wrapping_add(get_ivarint(bytes, &mut pos)?)
446        } else {
447            0
448        };
449        if pid != prev_pack_id {
450            expected_offset = 0;
451        }
452        prev_pack_id = pid;
453        if !(0..=u32::MAX as i64).contains(&pid) {
454            return Err(Error::InvalidArchive(format!(
455                "directory: pack_id {pid} out of u32 range"
456            )));
457        }
458        let pack_id = pid as u32;
459        let offset = if get_uvarint(bytes, &mut pos)? == 0 {
460            expected_offset
461        } else {
462            get_uvarint(bytes, &mut pos)?
463        };
464        let length = get_uvarint(bytes, &mut pos)? as u32;
465        let uncompressed_size = get_uvarint(bytes, &mut pos)? as u32;
466        let crc = u32::from_le_bytes(
467            bytes
468                .get(pos..pos + 4)
469                .ok_or_else(|| Error::InvalidArchive("directory: truncated crc".into()))?
470                .try_into()
471                .unwrap(),
472        );
473        pos += 4;
474
475        // Phrased as a subtraction (cursor ≤ n is a loop invariant) so a
476        // doctored run_len near u64::MAX can't overflow `cursor + run_len`.
477        if run_len > n - cursor {
478            return Err(Error::InvalidArchive(
479                "directory: run length exceeds entry count".into(),
480            ));
481        }
482        for _ in 0..run_len {
483            let k = &keys[cursor];
484            cursor += 1;
485            entries.push(TileEntry {
486                zoom: k.zoom,
487                x: k.x,
488                y: k.y,
489                time_start: k.time_start,
490                time_end: k.time_end,
491                pack_id,
492                offset,
493                length,
494                uncompressed_size,
495                feature_count: k.feature_count,
496                hilbert: k.hilbert,
497                crc32c: crc,
498                temporal_bucket_ms: k.temporal_bucket_ms,
499                cover_t_min: None,
500            });
501        }
502        expected_offset = offset.wrapping_add(length as u64);
503    }
504
505    if cursor != n {
506        return Err(Error::InvalidArchive(format!(
507            "directory: runs covered {cursor} entries, expected {n}"
508        )));
509    }
510
511    // Optional trailing covering section(s). A pre-covering archive's buffer
512    // ends here; if bytes remain, read tagged sections. Unknown tags stop the
513    // scan (forward-compat) rather than erroring.
514    if pos < bytes.len() {
515        let tag = bytes[pos];
516        pos += 1;
517        if tag == COVER_SECTION_TMIN {
518            for e in entries.iter_mut() {
519                let delta = get_ivarint(bytes, &mut pos)?;
520                e.cover_t_min = Some(e.time_start.wrapping_add(delta));
521            }
522        }
523    }
524
525    Ok(entries)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn entry(
533        zoom: u8,
534        x: u32,
535        y: u32,
536        hilbert: u64,
537        ts: i64,
538        te: i64,
539        offset: u64,
540        length: u32,
541        unc: u32,
542        fc: u32,
543        crc: u32,
544        tb: Option<u64>,
545    ) -> TileEntry {
546        TileEntry {
547            zoom,
548            x,
549            y,
550            time_start: ts,
551            time_end: te,
552            pack_id: 0,
553            offset,
554            length,
555            uncompressed_size: unc,
556            feature_count: fc,
557            hilbert,
558            crc32c: crc,
559            temporal_bucket_ms: tb,
560            cover_t_min: None,
561        }
562    }
563
564    /// Build an entry with an explicit `pack_id` (v5 packed format).
565    #[allow(clippy::too_many_arguments)]
566    fn entry_pack(
567        pack_id: u32,
568        zoom: u8,
569        x: u32,
570        y: u32,
571        hilbert: u64,
572        ts: i64,
573        te: i64,
574        offset: u64,
575        length: u32,
576        unc: u32,
577        fc: u32,
578        crc: u32,
579        tb: Option<u64>,
580    ) -> TileEntry {
581        let mut e = entry(zoom, x, y, hilbert, ts, te, offset, length, unc, fc, crc, tb);
582        e.pack_id = pack_id;
583        e
584    }
585
586    #[test]
587    fn empty_roundtrips() {
588        let bytes = encode_directory(&[]);
589        let back = decode_directory(&bytes).unwrap();
590        assert!(back.is_empty());
591    }
592
593    /// The optional covering section round-trips `cover_t_min` exactly (incl. a
594    /// value BELOW `time_start` — a feature starting before its bucket edge).
595    #[test]
596    fn cover_t_min_section_roundtrips() {
597        let mut entries = Vec::new();
598        for i in 0..20u32 {
599            let mut e = entry(
600                10, i, i, i as u64, (i as i64) * 1000, (i as i64) * 1000 + 900,
601                64 + i as u64 * 50, 50, 100, i, 0x100 + i, Some(1000),
602            );
603            // tight lower bound: usually inside the bucket, but entry 0 starts
604            // 500ms BEFORE its bucket boundary to exercise the signed delta.
605            e.cover_t_min = Some(if i == 0 { -500 } else { (i as i64) * 1000 + 250 });
606            entries.push(e);
607        }
608        let bytes = encode_directory(&entries);
609        let back = decode_directory(&bytes).unwrap();
610        let mut expected = entries.clone();
611        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
612        assert_eq!(back, expected);
613        assert!(back.iter().all(|e| e.cover_t_min.is_some()));
614    }
615
616    /// A directory with NO covering bounds must produce a buffer byte-identical
617    /// to the pre-covering codec (no trailing section), and decode to `None`.
618    #[test]
619    fn absent_cover_section_is_backward_compatible() {
620        let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
621        let bytes = encode_directory(std::slice::from_ref(&e));
622        // No trailing tag byte: the last byte is the run's crc (4 LE bytes),
623        // never a lone COVER_SECTION_TMIN tag.
624        let back = decode_directory(&bytes).unwrap();
625        assert_eq!(back.len(), 1);
626        assert_eq!(back[0].cover_t_min, None);
627    }
628
629    #[test]
630    fn single_entry_roundtrips() {
631        let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 0xDEAD_BEEF, Some(3_600_000));
632        let bytes = encode_directory(std::slice::from_ref(&e));
633        let back = decode_directory(&bytes).unwrap();
634        assert_eq!(back, vec![e]);
635    }
636
637    /// Distinct contiguous blobs: the offset column should ride the contiguity
638    /// sentinel, and every field must round-trip exactly.
639    #[test]
640    fn contiguous_distinct_blobs_roundtrip() {
641        let mut entries = Vec::new();
642        let mut offset = 64u64;
643        for i in 0..50u32 {
644            let len = 100 + i;
645            entries.push(entry(
646                12,
647                i,
648                i,
649                i as u64, // hilbert monotonic → already in directory order
650                (i as i64) * 1000,
651                (i as i64) * 1000 + 500,
652                offset,
653                len,
654                len * 2,
655                i,
656                0x1000 + i,
657                None,
658            ));
659            offset += len as u64;
660        }
661        let bytes = encode_directory(&entries);
662        let back = decode_directory(&bytes).unwrap();
663        assert_eq!(back, entries);
664    }
665
666    /// Temporal RLE: one spatial cell whose content is identical across many
667    /// time buckets (same blob → same offset/length/crc). The encoding must
668    /// collapse these into a single run yet decode back to every entry.
669    #[test]
670    fn identical_across_time_collapses_to_one_run_and_roundtrips() {
671        let crc = 0xABCD_1234u32;
672        let mut entries = Vec::new();
673        // 100 consecutive hourly buckets of one static cell, all the same blob.
674        for b in 0..100u64 {
675            entries.push(entry(
676                9,
677                3,
678                4,
679                77, // same hilbert (same cell)
680                (b as i64) * 3_600_000,
681                (b as i64) * 3_600_000 + 3_599_999,
682                4096,   // same offset (deduped blob)
683                512,    // same length
684                1024,   // same uncompressed
685                64,
686                crc,    // same crc
687                Some(3_600_000),
688            ));
689        }
690        let bytes = encode_directory(&entries);
691        let back = decode_directory(&bytes).unwrap();
692        assert_eq!(back, entries);
693
694        // The headline RLE win is on the per-blob columns (offset/length/
695        // uncompressed/crc): the static run stores them once instead of 100×.
696        // Compare against the same corpus with *distinct* blobs, where every
697        // entry needs its own run.
698        let mut distinct = entries.clone();
699        for (i, e) in distinct.iter_mut().enumerate() {
700            e.offset = 4096 + i as u64 * 512;
701            e.crc32c = 0x9000 + i as u32;
702        }
703        let distinct_bytes = encode_directory(&distinct);
704        eprintln!(
705            "static-cell RLE directory: {} bytes vs distinct-blob: {} bytes",
706            bytes.len(),
707            distinct_bytes.len()
708        );
709        // The blob columns are ~10 bytes/run. Collapsing 100 runs → 1 must save
710        // close to the full 99 × 10 bytes the distinct encoding spends on them.
711        assert!(
712            distinct_bytes.len() >= bytes.len() + 99 * 8,
713            "RLE should reclaim the per-blob columns: rle={}, distinct={}",
714            bytes.len(),
715            distinct_bytes.len()
716        );
717    }
718
719    /// A mixed corpus across several zooms, cells, times, with some shared
720    /// blobs and some unsorted input — the codec must sort, RLE, and round-trip.
721    #[test]
722    fn mixed_unsorted_corpus_roundtrips() {
723        let mut entries = Vec::new();
724        let mut off = 64u64;
725        for zoom in [4u8, 8, 12] {
726            for cell in 0..20u64 {
727                for t in 0..5i64 {
728                    let len = 80 + (cell as u32 % 7);
729                    // Every 3rd (cell,t) reuses the previous blob to exercise RLE.
730                    let shared = t > 0 && t % 3 != 0;
731                    let (offset, crc) = if shared {
732                        (off, 0x5555)
733                    } else {
734                        off += len as u64;
735                        (off, 0x6000 + cell as u32 + t as u32)
736                    };
737                    entries.push(entry(
738                        zoom,
739                        cell as u32,
740                        (cell * 2) as u32,
741                        cell * 10 + zoom as u64, // arbitrary but stable hilbert
742                        t * 1000 + cell as i64,
743                        t * 1000 + cell as i64 + 250,
744                        offset,
745                        len,
746                        len * 3,
747                        (cell + t as u64) as u32,
748                        crc,
749                        if zoom == 4 { Some(86_400_000) } else { None },
750                    ));
751                }
752            }
753        }
754        // Shuffle deterministically so encode must sort.
755        entries.reverse();
756
757        let bytes = encode_directory(&entries);
758        let back = decode_directory(&bytes).unwrap();
759
760        // Expected = the same entries in directory order.
761        let mut expected = entries.clone();
762        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
763        assert_eq!(back, expected);
764    }
765
766    #[test]
767    fn negative_and_extreme_times_roundtrip() {
768        let entries = vec![
769            entry(0, 0, 0, 0, i64::MIN + 1, i64::MIN + 10, 64, 8, 16, 1, 1, None),
770            entry(0, 0, 0, 0, -5000, -1000, 72, 8, 16, 1, 2, Some(1)),
771            entry(0, 0, 0, 0, 0, 0, 80, 8, 16, 1, 3, None),
772            entry(0, 0, 0, 0, i64::MAX - 10, i64::MAX, 88, 8, 16, 1, 4, None),
773        ];
774        let bytes = encode_directory(&entries);
775        let back = decode_directory(&bytes).unwrap();
776        assert_eq!(back, entries);
777    }
778
779    #[test]
780    fn truncated_buffer_errors() {
781        let e = entry(10, 5, 7, 42, 1000, 2000, 64, 128, 256, 3, 7, None);
782        let bytes = encode_directory(std::slice::from_ref(&e));
783        // Lop off the trailing crc bytes.
784        let truncated = &bytes[..bytes.len() - 2];
785        assert!(decode_directory(truncated).is_err());
786    }
787
788    #[test]
789    fn wrong_version_errors() {
790        let mut bytes = encode_directory(&[]);
791        bytes[0] = 99;
792        assert!(decode_directory(&bytes).is_err());
793    }
794
795    /// A foreign / corrupt directory whose zoom delta overflows u8 must error
796    /// rather than silently truncate via `as u8`.
797    #[test]
798    fn decode_rejects_out_of_range_columns() {
799        let mut buf = Vec::new();
800        buf.push(DIRECTORY_VERSION);
801        put_uvarint(&mut buf, 1); // N
802        put_uvarint(&mut buf, 1); // R
803        put_ivarint(&mut buf, 300); // Δzoom (out of u8 range)
804        put_ivarint(&mut buf, 0); // Δhilbert
805        put_ivarint(&mut buf, 0); // Δx
806        put_ivarint(&mut buf, 0); // Δy
807        put_ivarint(&mut buf, 0); // Δtime_start
808        put_ivarint(&mut buf, 0); // duration
809        put_uvarint(&mut buf, 0); // feature_count
810        put_uvarint(&mut buf, 0); // bucket present = 0
811        put_uvarint(&mut buf, 1); // run_len
812        put_ivarint(&mut buf, 0); // Δpack_id (v5)
813        put_uvarint(&mut buf, 0); // offset flag (contiguous)
814        put_uvarint(&mut buf, 0); // length
815        put_uvarint(&mut buf, 0); // uncompressed
816        buf.extend_from_slice(&0u32.to_le_bytes()); // crc
817        assert!(decode_directory(&buf).is_err());
818    }
819
820    /// Boundary: u64::MAX must round-trip for both the blob offset and the
821    /// temporal bucket — neither sentinel may collide with a real value.
822    #[test]
823    fn u64_max_offset_and_bucket_roundtrip() {
824        let entries = vec![
825            entry(5, 1, 1, 10, 0, 100, u64::MAX, 8, 16, 1, 7, Some(u64::MAX)),
826            entry(5, 2, 2, 11, 0, 100, 64, 8, 16, 1, 8, Some(3_600_000)),
827            entry(5, 3, 3, 12, 0, 100, 0, 8, 16, 1, 9, None),
828        ];
829        let bytes = encode_directory(&entries);
830        let back = decode_directory(&bytes).unwrap();
831        assert_eq!(back, entries);
832    }
833
834    /// v5: entries spread across multiple packs round-trip with the correct
835    /// `pack_id`, and each pack's offsets are pack-relative (every pack starts
836    /// from a small offset, riding the contiguity sentinel reset).
837    #[test]
838    fn multi_pack_offsets_are_pack_relative_and_roundtrip() {
839        let mut entries = Vec::new();
840        // Three packs, each with a fresh offset run starting at 0. Distinct
841        // blobs within a pack are contiguous (offset += length).
842        for pack_id in 0..3u32 {
843            let mut off = 0u64;
844            for i in 0..6u32 {
845                let hil = (pack_id as u64) * 100 + i as u64; // monotone in dir order
846                let len = 40 + i;
847                entries.push(entry_pack(
848                    pack_id,
849                    9,
850                    pack_id * 10 + i,
851                    i,
852                    hil,
853                    (i as i64) * 1000,
854                    (i as i64) * 1000 + 500,
855                    off,
856                    len,
857                    len * 2,
858                    i,
859                    0x2000 + pack_id * 100 + i,
860                    Some(3_600_000),
861                ));
862                off += len as u64;
863            }
864        }
865        let bytes = encode_directory(&entries);
866        let back = decode_directory(&bytes).unwrap();
867        let mut expected = entries.clone();
868        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
869        assert_eq!(back, expected);
870        // Every pack contributes entries, and the first run of each pack has
871        // offset 0 (pack-relative reset). pack_ids observed are exactly {0,1,2}.
872        let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
873        assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
874        for p in 0..3u32 {
875            assert!(back.iter().any(|e| e.pack_id == p && e.offset == 0));
876        }
877    }
878
879    /// v5: RLE within a pack still collapses a static cell across time, but two
880    /// blobs that are byte-identical in *different* packs must NOT collapse
881    /// (pack_id is part of the run identity), and both decode to their own pack.
882    #[test]
883    fn rle_collapses_within_pack_but_not_across_packs() {
884        let crc = 0x55AA_55AAu32;
885        let mut entries = Vec::new();
886        // Pack 0: one static cell across 50 hourly buckets — one run.
887        for b in 0..50u64 {
888            entries.push(entry_pack(
889                0, 9, 3, 4, 77,
890                (b as i64) * 3_600_000,
891                (b as i64) * 3_600_000 + 3_599_999,
892                4096, 512, 1024, 64, crc, Some(3_600_000),
893            ));
894        }
895        // Pack 1: same blob fields (offset/len/unc/crc) but a different pack —
896        // a separate physical object, so it must stay its own run.
897        for b in 0..50u64 {
898            entries.push(entry_pack(
899                1, 9, 5, 6, 99,
900                (b as i64) * 3_600_000,
901                (b as i64) * 3_600_000 + 3_599_999,
902                4096, 512, 1024, 64, crc, Some(3_600_000),
903            ));
904        }
905        let bytes = encode_directory(&entries);
906        let back = decode_directory(&bytes).unwrap();
907        let mut expected = entries.clone();
908        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
909        assert_eq!(back, expected);
910        assert!(back.iter().any(|e| e.pack_id == 0));
911        assert!(back.iter().any(|e| e.pack_id == 1));
912    }
913
914    /// v5: the cover section still round-trips alongside multi-pack pack_ids.
915    #[test]
916    fn cover_section_roundtrips_with_pack_ids() {
917        let mut entries = Vec::new();
918        for i in 0..12u32 {
919            let pack_id = i / 4; // packs 0,1,2
920            let mut e = entry_pack(
921                pack_id, 10, i, i, i as u64,
922                (i as i64) * 1000, (i as i64) * 1000 + 900,
923                (i % 4) as u64 * 50, 50, 100, i, 0x300 + i, Some(1000),
924            );
925            e.cover_t_min = Some((i as i64) * 1000 + 250);
926            entries.push(e);
927        }
928        let bytes = encode_directory(&entries);
929        let back = decode_directory(&bytes).unwrap();
930        let mut expected = entries.clone();
931        expected.sort_by_key(|e| (e.zoom, e.hilbert, e.time_start));
932        assert_eq!(back, expected);
933        assert!(back.iter().all(|e| e.cover_t_min.is_some()));
934        let packs: std::collections::BTreeSet<u32> = back.iter().map(|e| e.pack_id).collect();
935        assert_eq!(packs, [0u32, 1, 2].into_iter().collect());
936    }
937}