Skip to main content

stt_core/
metadata.rs

1//! Archive metadata structures
2//!
3//! This module provides types for storing and managing archive metadata.
4
5use crate::error::{Error, Result};
6use crate::types::{BoundingBox, TimeRange};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Aggregation scheme for the optional pre-aggregated summary tier.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SummaryScheme {
14    /// Uber H3 hexagonal cells.
15    H3,
16    /// CARTO Quadbin (Z/X/Y quad-key encoded as u64).
17    Quadbin,
18}
19
20/// One aggregated column in a summary tier.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SummaryAggregation {
24    Count,
25    Sum,
26    Mean,
27    Min,
28    Max,
29}
30
31/// Description of a single column emitted by the summary tier.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SummaryColumn {
34    pub name: String,
35    pub agg: SummaryAggregation,
36}
37
38/// Description of the optional pre-aggregated summary tier.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SummaryTier {
41    pub scheme: SummaryScheme,
42    pub min_zoom: u8,
43    pub max_zoom: u8,
44    pub cell_resolution_per_zoom: Vec<u8>,
45    pub columns: Vec<SummaryColumn>,
46    #[serde(default = "default_summary_layer_name")]
47    pub layer_name: String,
48    /// Number of fine-grained sub-buckets per outer time-bucket emitted
49    /// at build time. `1` (or absent) = legacy single-count behaviour.
50    /// When > 1, each cell row carries N additional numeric columns named
51    /// `bucket_0`..`bucket_<N-1>` and the renderer animates inside a tile
52    /// by switching which column drives the per-cell colour — zero data
53    /// re-upload between frames.
54    #[serde(default = "default_sub_buckets")]
55    pub sub_buckets: u32,
56}
57
58fn default_sub_buckets() -> u32 {
59    1
60}
61
62fn default_summary_layer_name() -> String {
63    "summary".to_string()
64}
65
66impl SummaryTier {
67    /// Resolution to use at a given zoom. Falls back to the closest mapped
68    /// resolution if the zoom is outside `[min_zoom, max_zoom]`.
69    pub fn resolution_for_zoom(&self, zoom: u8) -> u8 {
70        if self.cell_resolution_per_zoom.is_empty() {
71            return zoom;
72        }
73        if zoom <= self.min_zoom {
74            return self.cell_resolution_per_zoom[0];
75        }
76        if zoom >= self.max_zoom {
77            return *self
78                .cell_resolution_per_zoom
79                .last()
80                .expect("non-empty per check");
81        }
82        let idx = (zoom - self.min_zoom) as usize;
83        self.cell_resolution_per_zoom
84            .get(idx)
85            .copied()
86            .unwrap_or_else(|| {
87                *self.cell_resolution_per_zoom.last().unwrap()
88            })
89    }
90}
91
92/// Bake-time per-class intensity domain for the GPU-splat HeatmapLayer.
93///
94/// The HeatmapLayer maps `(weight × gaussian_falloff × intensity)` through a
95/// palette LUT. Without a pinned domain the renderer would either bake `[0,1]`
96/// in (saturating immediately when `weightProperty` carries large values like
97/// earthquake magnitudes) or trigger a runtime GPU readback to auto-detect
98/// the max. Computing the domain at build time gives the renderer a stable
99/// ramp with zero runtime cost.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct HeatmapClassDomain {
102    /// Class id — `"default"` for the un-classified single-channel mode,
103    /// otherwise matches the FE channel id (typically a categorical value).
104    pub id: String,
105    /// Inclusive minimum splat intensity for this class.
106    pub min: f64,
107    /// Inclusive maximum splat intensity for this class. For the
108    /// un-weighted default this is 1.0 (the gaussian peak). For a
109    /// weight-property-driven layer this is the 95th-percentile weight
110    /// across all features (95p is more visually useful than absolute max,
111    /// which lets a single outlier dim the whole ramp).
112    pub max: f64,
113    /// Source weight property the domain was computed from, if any.
114    /// `None` = constant unit weight.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub property: Option<String>,
117}
118
119/// Container for the build-time HeatmapLayer domain metadata.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct HeatmapDomain {
122    pub classes: Vec<HeatmapClassDomain>,
123}
124
125/// One level of a temporal LOD pyramid (orthogonal to the summary tier above).
126///
127/// At any tile-zoom-level `z` such that `z <= max_zoom_level`, a client that
128/// is currently displaying a time range too wide to render the base
129/// `temporal_bucket_ms` tiles efficiently can fetch coarser tiles from this
130/// level instead. Each level uses `bucket_ms` as its temporal bucket size
131/// (which must be a multiple of the archive's base `temporal_bucket_ms`).
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct TemporalLodLevel {
134    /// Temporal bucket size in milliseconds for tiles at this level.
135    pub bucket_ms: u64,
136    /// Inclusive upper bound on the spatial zoom level where this LOD applies.
137    pub max_zoom_level: u8,
138}
139
140/// Archive metadata.
141///
142/// Stored in the archive as UTF-8 JSON — small, human-inspectable, and
143/// versionless thanks to serde's field defaults.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct Metadata {
146    /// Archive name
147    pub name: String,
148    /// Description
149    pub description: String,
150    /// Attribution text
151    pub attribution: String,
152    /// Bounding box
153    pub bounds: BoundingBox,
154    /// Time range
155    pub time_range: TimeRange,
156    /// Minimum zoom level
157    pub min_zoom: u8,
158    /// Maximum zoom level
159    pub max_zoom: u8,
160    /// Total number of tiles
161    pub tile_count: u64,
162    /// Total number of features
163    pub feature_count: u64,
164    /// Layer names
165    pub layers: Vec<String>,
166    /// Custom properties
167    pub properties: HashMap<String, String>,
168    /// Temporal bucket size in milliseconds for tile chunking
169    /// Tiles are organized into fixed temporal intervals (e.g., 3600000 = 1 hour)
170    pub temporal_bucket_ms: u64,
171    /// Optional server-side aggregated summary tier. v2/v3 archives without
172    /// a summary tier round-trip cleanly via the field default.
173    #[serde(default)]
174    pub summary_tier: Option<SummaryTier>,
175
176    /// Optional temporal LOD pyramid (orthogonal to summary tier).
177    /// When present, the archive carries aggregate tiles at coarser temporal
178    /// granularities so a reader animating decades of data at "year scale"
179    /// can fetch coarser tiles instead of streaming per-hour base tiles.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub temporal_lod: Option<Vec<TemporalLodLevel>>,
182
183    /// Optional bake-time HeatmapLayer intensity domains. When set, the
184    /// renderer's HeatmapLayer skips its runtime `colorDomain` default of
185    /// `[0, 1]` and uses these per-class entries instead — vital when the
186    /// configured `weightProperty` carries values far outside that range
187    /// (earthquake magnitudes, AIS speed, etc.).
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub heatmap_domain: Option<HeatmapDomain>,
190}
191
192impl Default for Metadata {
193    fn default() -> Self {
194        Self {
195            name: String::new(),
196            description: String::new(),
197            attribution: String::new(),
198            bounds: BoundingBox {
199                min_lon: -180.0,
200                min_lat: -85.0511,
201                max_lon: 180.0,
202                max_lat: 85.0511,
203            },
204            time_range: TimeRange::new(0, 0),
205            min_zoom: 0,
206            max_zoom: 14,
207            tile_count: 0,
208            feature_count: 0,
209            layers: vec!["default".to_string()],
210            properties: HashMap::new(),
211            temporal_bucket_ms: 3600 * 1000, // 1 hour default
212            summary_tier: None,
213            temporal_lod: None,
214            heatmap_domain: None,
215        }
216    }
217}
218
219impl Metadata {
220    /// Create a new metadata object
221    pub fn new(name: impl Into<String>) -> Self {
222        Self {
223            name: name.into(),
224            ..Default::default()
225        }
226    }
227
228    /// Set description
229    pub fn with_description(mut self, description: impl Into<String>) -> Self {
230        self.description = description.into();
231        self
232    }
233
234    /// Set attribution
235    pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
236        self.attribution = attribution.into();
237        self
238    }
239
240    /// Set bounds
241    pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
242        self.bounds = bounds;
243        self
244    }
245
246    /// Set time range
247    pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
248        self.time_range = time_range;
249        self
250    }
251
252    /// Set zoom levels
253    pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
254        self.min_zoom = min_zoom;
255        self.max_zoom = max_zoom;
256        self
257    }
258
259    /// Set temporal bucket size in milliseconds
260    pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
261        self.temporal_bucket_ms = temporal_bucket_ms;
262        self
263    }
264
265    /// Attach a temporal LOD pyramid.
266    ///
267    /// Each level's `bucket_ms` MUST be a strict multiple of the archive's
268    /// base `temporal_bucket_ms` and MUST be strictly greater than it; levels
269    /// MUST be sorted by ascending `bucket_ms`. Returns `Err` if the input
270    /// breaks any of those invariants — the build pipeline relies on them
271    /// when re-bucketing features into LOD aggregates.
272    pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
273        validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
274        self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
275        Ok(self)
276    }
277
278    /// Return the LOD level that applies at `zoom`, if any. The largest
279    /// matching `bucket_ms` (coarsest level) wins — at a global zoom, you
280    /// want the coarsest available aggregate, not the finest.
281    pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
282        let levels = self.temporal_lod.as_ref()?;
283        levels
284            .iter()
285            .filter(|l| zoom <= l.max_zoom_level)
286            .max_by_key(|l| l.bucket_ms)
287    }
288
289    /// Add a custom property
290    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
291        self.properties.insert(key.into(), value.into());
292        self
293    }
294
295    /// Attach an aggregated summary-tier descriptor.
296    pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
297        self.summary_tier = Some(tier);
298        self
299    }
300
301    /// Attach a bake-time HeatmapLayer intensity-domain block.
302    pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
303        self.heatmap_domain = Some(domain);
304        self
305    }
306
307    /// Serialise to the JSON byte form stored in an archive.
308    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
309        serde_json::to_vec(self)
310            .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
311    }
312
313    /// Parse from the JSON byte form stored in an archive.
314    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
315        serde_json::from_slice(bytes)
316            .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
317    }
318
319    /// Render a TileJSON 3.0 descriptor (with a STAC-style `temporal`
320    /// extension) for this archive.
321    ///
322    /// This is the self-describing, ecosystem-recognisable face of the archive:
323    /// MapLibre / Leaflet / OpenLayers understand the core TileJSON fields, and
324    /// the additive `temporal` block — an ISO-8601 `interval` à la STAC, plus
325    /// the bucket size/step and any LOD pyramid — carries the time dimension the
326    /// spatial-only standard lacks (unknown keys are ignored by existing
327    /// clients). `tile_url_template` is the `{z}/{x}/{y}/{t}` URL the host serves
328    /// tiles at; when `None`, a relative template is emitted.
329    pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
330        use serde_json::json;
331        let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
332        let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
333        let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
334
335        let to_iso = |ms: u64| -> serde_json::Value {
336            // Guard the u64→i64 cast: a timestamp beyond i64::MAX ms (year ~292M)
337            // would wrap negative; surface it as a null open bound instead.
338            if ms > i64::MAX as u64 {
339                return serde_json::Value::Null;
340            }
341            chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
342                .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
343                .unwrap_or(serde_json::Value::Null)
344        };
345
346        let vector_layers: Vec<serde_json::Value> = self
347            .layers
348            .iter()
349            .map(|name| {
350                json!({
351                    "id": name,
352                    "fields": {},
353                    "minzoom": self.min_zoom,
354                    "maxzoom": self.max_zoom,
355                })
356            })
357            .collect();
358
359        // STAC temporal extent: interval is an array of [start, end] pairs with
360        // `null` for an open end. We carry the bucket size + ISO-8601 step and
361        // any LOD pyramid as additive keys.
362        let mut temporal = json!({
363            "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
364            "bucket_ms": self.temporal_bucket_ms,
365        });
366        if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
367            temporal["step"] = json!(step);
368        }
369        if let Some(levels) = &self.temporal_lod {
370            temporal["lod"] = json!(levels
371                .iter()
372                .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
373                .collect::<Vec<_>>());
374        }
375
376        json!({
377            "tilejson": "3.0.0",
378            "tiles": [tiles],
379            "name": self.name,
380            "description": self.description,
381            "attribution": self.attribution,
382            "scheme": "xyz",
383            "version": "1.0.0",
384            "minzoom": self.min_zoom,
385            "maxzoom": self.max_zoom,
386            "bounds": [
387                self.bounds.min_lon,
388                self.bounds.min_lat,
389                self.bounds.max_lon,
390                self.bounds.max_lat
391            ],
392            "center": [center_lon, center_lat, self.min_zoom],
393            "vector_layers": vector_layers,
394            "temporal": temporal,
395        })
396    }
397}
398
399/// Verify the LOD invariants: ascending bucket order, multiples of the base
400/// bucket, strictly coarser than base, distinct bucket sizes.
401fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
402    if base_bucket_ms == 0 {
403        return Err(Error::Other(
404            "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
405        ));
406    }
407    let mut prev: Option<u64> = None;
408    for (i, level) in levels.iter().enumerate() {
409        if level.bucket_ms == 0 {
410            return Err(Error::Other(format!(
411                "temporal_lod[{i}].bucket_ms must be non-zero"
412            )));
413        }
414        if level.bucket_ms <= base_bucket_ms {
415            return Err(Error::Other(format!(
416                "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
417                level.bucket_ms, base_bucket_ms
418            )));
419        }
420        if level.bucket_ms % base_bucket_ms != 0 {
421            return Err(Error::Other(format!(
422                "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
423                level.bucket_ms, base_bucket_ms
424            )));
425        }
426        if let Some(p) = prev {
427            if level.bucket_ms <= p {
428                return Err(Error::Other(format!(
429                    "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
430                    level.bucket_ms, p
431                )));
432            }
433        }
434        prev = Some(level.bucket_ms);
435    }
436    Ok(())
437}
438
439/// Format a millisecond duration as an ISO-8601 duration string for the
440/// TileJSON `temporal.step` field (best-effort: days / hours / minutes /
441/// seconds). Returns `None` for a zero bucket.
442fn iso8601_duration(ms: u64) -> Option<String> {
443    if ms == 0 {
444        return None;
445    }
446    Some(if ms % 86_400_000 == 0 {
447        format!("P{}D", ms / 86_400_000)
448    } else if ms % 3_600_000 == 0 {
449        format!("PT{}H", ms / 3_600_000)
450    } else if ms % 60_000 == 0 {
451        format!("PT{}M", ms / 60_000)
452    } else if ms % 1000 == 0 {
453        format!("PT{}S", ms / 1000)
454    } else {
455        format!("PT{:.3}S", ms as f64 / 1000.0)
456    })
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_metadata_json_roundtrip() {
465        let metadata = Metadata::new("json-test")
466            .with_description("desc")
467            .with_zoom_levels(2, 12)
468            .with_temporal_bucket_ms(3_600_000)
469            .with_property("source", "unit-test");
470        let bytes = metadata.to_json_bytes().unwrap();
471        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
472        assert_eq!(decoded.name, "json-test");
473        assert_eq!(decoded.min_zoom, 2);
474        assert_eq!(decoded.max_zoom, 12);
475        assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
476        assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
477    }
478
479    #[test]
480    fn test_metadata_summary_tier_roundtrip() {
481        let tier = SummaryTier {
482            scheme: SummaryScheme::H3,
483            min_zoom: 0,
484            max_zoom: 4,
485            cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
486            columns: vec![
487                SummaryColumn {
488                    name: "magnitude".to_string(),
489                    agg: SummaryAggregation::Mean,
490                },
491                SummaryColumn {
492                    name: "magnitude".to_string(),
493                    agg: SummaryAggregation::Max,
494                },
495            ],
496            layer_name: "summary".to_string(),
497            sub_buckets: 1,
498        };
499        let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
500        let bytes = metadata.to_json_bytes().unwrap();
501        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
502        let dt = decoded.summary_tier.unwrap();
503        assert_eq!(dt.scheme, SummaryScheme::H3);
504        assert_eq!(dt.min_zoom, 0);
505        assert_eq!(dt.max_zoom, 4);
506        assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
507        assert_eq!(dt.columns.len(), 2);
508        assert_eq!(dt.resolution_for_zoom(2), 2);
509        // Out-of-range zooms clamp to the endpoints.
510        assert_eq!(dt.resolution_for_zoom(10), 4);
511    }
512
513    #[test]
514    fn test_metadata_ignores_unknown_fields() {
515        // Forward compat: metadata written by a newer builder (or carrying
516        // since-removed fields like the old `raster_tier` scaffold) must
517        // still decode — serde skips unknown keys by default.
518        let metadata = Metadata::new("fwd");
519        let mut v: serde_json::Value =
520            serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
521        v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
522        let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
523        assert_eq!(decoded.name, "fwd");
524    }
525
526    #[test]
527    fn test_heatmap_domain_roundtrip() {
528        let domain = HeatmapDomain {
529            classes: vec![
530                HeatmapClassDomain {
531                    id: "pickup".to_string(),
532                    min: 0.0,
533                    max: 7.5,
534                    property: Some("intensity".to_string()),
535                },
536                HeatmapClassDomain {
537                    id: "dropoff".to_string(),
538                    min: 0.0,
539                    max: 9.0,
540                    property: None,
541                },
542            ],
543        };
544        let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
545        let bytes = metadata.to_json_bytes().unwrap();
546        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
547        let d = decoded.heatmap_domain.unwrap();
548        assert_eq!(d.classes.len(), 2);
549        assert_eq!(d.classes[0].id, "pickup");
550        assert_eq!(d.classes[0].max, 7.5);
551        assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
552        assert_eq!(d.classes[1].id, "dropoff");
553        assert_eq!(d.classes[1].property, None);
554    }
555
556    #[test]
557    fn test_heatmap_domain_field_omitted_when_unset() {
558        let metadata = Metadata::new("no-heat");
559        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
560        assert!(!s.contains("heatmap_domain"), "got: {s}");
561    }
562
563    #[test]
564    fn test_metadata_without_summary_tier_decodes() {
565        // A pre-summary-tier archive's metadata JSON has no `summary_tier`
566        // field at all. serde's `#[default]` must accept it.
567        let json = br#"{
568            "name": "old",
569            "description": "",
570            "attribution": "",
571            "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
572            "time_range": {"start":0,"end":1000},
573            "min_zoom": 0,
574            "max_zoom": 8,
575            "tile_count": 0,
576            "feature_count": 0,
577            "layers": ["default"],
578            "properties": {},
579            "temporal_bucket_ms": 3600000
580        }"#;
581        let m = Metadata::from_json_bytes(json).unwrap();
582        assert!(m.summary_tier.is_none());
583    }
584
585    #[test]
586    fn test_metadata_builder() {
587        let metadata = Metadata::new("test")
588            .with_description("Test archive")
589            .with_attribution("Test data")
590            .with_zoom_levels(0, 14)
591            .with_property("key", "value");
592
593        assert_eq!(metadata.name, "test");
594        assert_eq!(metadata.description, "Test archive");
595        assert_eq!(metadata.min_zoom, 0);
596        assert_eq!(metadata.max_zoom, 14);
597        assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
598    }
599
600    // ------------------------------------------------------------------
601    // temporal_lod
602    // ------------------------------------------------------------------
603
604    fn hour() -> u64 {
605        3_600_000
606    }
607    fn day() -> u64 {
608        24 * hour()
609    }
610    fn thirty_days() -> u64 {
611        30 * day()
612    }
613
614    #[test]
615    fn temporal_lod_roundtrips_through_json() {
616        let levels = vec![
617            TemporalLodLevel {
618                bucket_ms: day(),
619                max_zoom_level: 8,
620            },
621            TemporalLodLevel {
622                bucket_ms: thirty_days(),
623                max_zoom_level: 4,
624            },
625        ];
626        let metadata = Metadata::new("lod")
627            .with_temporal_bucket_ms(hour())
628            .with_temporal_lod(levels.clone())
629            .unwrap();
630        let bytes = metadata.to_json_bytes().unwrap();
631        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
632        assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
633    }
634
635    #[test]
636    fn temporal_lod_field_omitted_when_unset() {
637        // Older readers that don't know about temporal_lod must still parse
638        // a freshly-written archive; the field is skipped when None.
639        let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
640        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
641        assert!(!s.contains("temporal_lod"), "got: {s}");
642    }
643
644    #[test]
645    fn temporal_lod_missing_field_decodes_back_compat() {
646        // A v3 archive built before this feature has no `temporal_lod` key
647        // in its metadata JSON; the new field must default to None.
648        let legacy = r#"{
649            "name": "legacy",
650            "description": "",
651            "attribution": "",
652            "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
653            "time_range": {"start": 0, "end": 1},
654            "min_zoom": 0,
655            "max_zoom": 14,
656            "tile_count": 0,
657            "feature_count": 0,
658            "layers": ["default"],
659            "properties": {},
660            "temporal_bucket_ms": 3600000
661        }"#;
662        let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
663        assert!(m.temporal_lod.is_none());
664    }
665
666    #[test]
667    fn temporal_lod_rejects_non_multiple_bucket() {
668        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
669            TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
670        ]);
671        assert!(res.is_err());
672    }
673
674    #[test]
675    fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
676        let res = Metadata::new("bad")
677            .with_temporal_bucket_ms(day())
678            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
679        assert!(res.is_err());
680
681        let res = Metadata::new("bad")
682            .with_temporal_bucket_ms(hour())
683            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
684        assert!(res.is_err());
685    }
686
687    #[test]
688    fn temporal_lod_rejects_unsorted_levels() {
689        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
690            TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
691            TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
692        ]);
693        assert!(res.is_err());
694    }
695
696    #[test]
697    fn temporal_lod_for_zoom_picks_coarsest_applicable() {
698        let m = Metadata::new("lod")
699            .with_temporal_bucket_ms(hour())
700            .with_temporal_lod(vec![
701                TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
702                TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
703            ])
704            .unwrap();
705        // Very-zoomed-out: both levels apply, pick the coarser (30d).
706        assert_eq!(
707            m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
708            Some(thirty_days())
709        );
710        // Mid zoom: only the day level applies.
711        assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
712        // High zoom: no LOD — fall back to base bucket.
713        assert!(m.temporal_lod_for_zoom(12).is_none());
714    }
715
716    #[test]
717    fn temporal_lod_for_zoom_is_none_when_unset() {
718        let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
719        assert!(m.temporal_lod_for_zoom(0).is_none());
720    }
721
722    #[test]
723    fn temporal_lod_empty_vec_clears_to_none() {
724        // Passing an empty list is treated as "no LOD" rather than an error,
725        // so callers can compute the level set unconditionally.
726        let m = Metadata::new("empty")
727            .with_temporal_bucket_ms(hour())
728            .with_temporal_lod(vec![])
729            .unwrap();
730        assert!(m.temporal_lod.is_none());
731    }
732
733    #[test]
734    fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
735        let m = Metadata::new("quakes")
736            .with_description("USGS earthquakes")
737            .with_attribution("USGS")
738            .with_zoom_levels(0, 10)
739            .with_temporal_bucket_ms(hour())
740            .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
741            .with_temporal_lod(vec![TemporalLodLevel {
742                bucket_ms: day(),
743                max_zoom_level: 6,
744            }])
745            .unwrap();
746        let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
747
748        // Core TileJSON 3.0 fields every web client recognises.
749        assert_eq!(tj["tilejson"], "3.0.0");
750        assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
751        assert_eq!(tj["minzoom"], 0);
752        assert_eq!(tj["maxzoom"], 10);
753        assert_eq!(tj["scheme"], "xyz");
754        assert_eq!(tj["vector_layers"][0]["id"], "default");
755        assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
756
757        // Additive STAC-style temporal extension.
758        assert_eq!(tj["temporal"]["step"], "PT1H");
759        assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
760        let interval = &tj["temporal"]["interval"][0];
761        assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
762        assert!(interval[1].is_string());
763        assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
764    }
765
766    #[test]
767    fn iso8601_duration_formats_common_buckets() {
768        assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
769        assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
770        assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
771        assert_eq!(iso8601_duration(0), None);
772    }
773
774    #[test]
775    fn tilejson_time_beyond_i64_is_null_not_garbage() {
776        // A timestamp past i64::MAX ms must surface as a null open bound rather
777        // than wrapping to a negative (bogus) date.
778        let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
779        let tj = m.to_tilejson(None);
780        assert!(tj["temporal"]["interval"][0][0].is_null());
781        assert!(tj["temporal"]["interval"][0][1].is_null());
782    }
783}