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. For packed manifests this is derived from the
161    /// directory at write time (`PackWriter::finalize`); caller-set values are
162    /// ignored there.
163    pub tile_count: u64,
164    /// Total feature records summed across tiles — a feature that lands in N
165    /// tiles (zoom pyramid, clipping, temporal LOD) counts N times. Matches
166    /// stt-validate's `feature_count_index`; derived from the directory at
167    /// write time for packed manifests.
168    pub feature_count: u64,
169    /// Layer names
170    pub layers: Vec<String>,
171    /// Custom properties
172    pub properties: HashMap<String, String>,
173    /// Temporal bucket size in milliseconds for tile chunking
174    /// Tiles are organized into fixed temporal intervals (e.g., 3600000 = 1 hour)
175    pub temporal_bucket_ms: u64,
176    /// Optional server-side aggregated summary tier. v2/v3 archives without
177    /// a summary tier round-trip cleanly via the field default.
178    #[serde(default)]
179    pub summary_tier: Option<SummaryTier>,
180
181    /// Optional temporal LOD pyramid (orthogonal to summary tier).
182    /// When present, the archive carries aggregate tiles at coarser temporal
183    /// granularities so a reader animating decades of data at "year scale"
184    /// can fetch coarser tiles instead of streaming per-hour base tiles.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub temporal_lod: Option<Vec<TemporalLodLevel>>,
187
188    /// Optional bake-time HeatmapLayer intensity domains. When set, the
189    /// renderer's HeatmapLayer skips its runtime `colorDomain` default of
190    /// `[0, 1]` and uses these per-class entries instead — vital when the
191    /// configured `weightProperty` carries values far outside that range
192    /// (earthquake magnitudes, AIS speed, etc.).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub heatmap_domain: Option<HeatmapDomain>,
195}
196
197impl Default for Metadata {
198    fn default() -> Self {
199        Self {
200            name: String::new(),
201            description: String::new(),
202            attribution: String::new(),
203            bounds: BoundingBox {
204                min_lon: -180.0,
205                min_lat: -85.0511,
206                max_lon: 180.0,
207                max_lat: 85.0511,
208            },
209            time_range: TimeRange::new(0, 0),
210            min_zoom: 0,
211            max_zoom: 14,
212            tile_count: 0,
213            feature_count: 0,
214            layers: vec!["default".to_string()],
215            properties: HashMap::new(),
216            temporal_bucket_ms: 3600 * 1000, // 1 hour default
217            summary_tier: None,
218            temporal_lod: None,
219            heatmap_domain: None,
220        }
221    }
222}
223
224impl Metadata {
225    /// Create a new metadata object
226    pub fn new(name: impl Into<String>) -> Self {
227        Self {
228            name: name.into(),
229            ..Default::default()
230        }
231    }
232
233    /// Set description
234    pub fn with_description(mut self, description: impl Into<String>) -> Self {
235        self.description = description.into();
236        self
237    }
238
239    /// Set attribution
240    pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
241        self.attribution = attribution.into();
242        self
243    }
244
245    /// Set bounds
246    pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
247        self.bounds = bounds;
248        self
249    }
250
251    /// Set time range
252    pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
253        self.time_range = time_range;
254        self
255    }
256
257    /// Set zoom levels
258    pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
259        self.min_zoom = min_zoom;
260        self.max_zoom = max_zoom;
261        self
262    }
263
264    /// Set temporal bucket size in milliseconds
265    pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
266        self.temporal_bucket_ms = temporal_bucket_ms;
267        self
268    }
269
270    /// Attach a temporal LOD pyramid.
271    ///
272    /// Each level's `bucket_ms` MUST be a strict multiple of the archive's
273    /// base `temporal_bucket_ms` and MUST be strictly greater than it; levels
274    /// MUST be sorted by ascending `bucket_ms`. Returns `Err` if the input
275    /// breaks any of those invariants — the build pipeline relies on them
276    /// when re-bucketing features into LOD aggregates.
277    pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
278        validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
279        self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
280        Ok(self)
281    }
282
283    /// Return the LOD level that applies at `zoom`, if any. The largest
284    /// matching `bucket_ms` (coarsest level) wins — at a global zoom, you
285    /// want the coarsest available aggregate, not the finest.
286    pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
287        let levels = self.temporal_lod.as_ref()?;
288        levels
289            .iter()
290            .filter(|l| zoom <= l.max_zoom_level)
291            .max_by_key(|l| l.bucket_ms)
292    }
293
294    /// Add a custom property
295    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
296        self.properties.insert(key.into(), value.into());
297        self
298    }
299
300    /// Attach an aggregated summary-tier descriptor.
301    pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
302        self.summary_tier = Some(tier);
303        self
304    }
305
306    /// Attach a bake-time HeatmapLayer intensity-domain block.
307    pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
308        self.heatmap_domain = Some(domain);
309        self
310    }
311
312    /// Serialise to the JSON byte form stored in an archive.
313    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
314        serde_json::to_vec(self)
315            .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
316    }
317
318    /// Parse from the JSON byte form stored in an archive.
319    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
320        serde_json::from_slice(bytes)
321            .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
322    }
323
324    /// Render a TileJSON 3.0 descriptor (with a STAC-style `temporal`
325    /// extension) for this archive.
326    ///
327    /// This is the self-describing, ecosystem-recognisable face of the archive:
328    /// MapLibre / Leaflet / OpenLayers understand the core TileJSON fields, and
329    /// the additive `temporal` block — an ISO-8601 `interval` à la STAC, plus
330    /// the bucket size/step and any LOD pyramid — carries the time dimension the
331    /// spatial-only standard lacks (unknown keys are ignored by existing
332    /// clients). `tile_url_template` is the `{z}/{x}/{y}/{t}` URL the host serves
333    /// tiles at; when `None`, a relative template is emitted.
334    pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
335        use serde_json::json;
336        let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
337        let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
338        let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
339
340        let to_iso = |ms: u64| -> serde_json::Value {
341            // Guard the u64→i64 cast: a timestamp beyond i64::MAX ms (year ~292M)
342            // would wrap negative; surface it as a null open bound instead.
343            if ms > i64::MAX as u64 {
344                return serde_json::Value::Null;
345            }
346            chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
347                .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
348                .unwrap_or(serde_json::Value::Null)
349        };
350
351        let vector_layers: Vec<serde_json::Value> = self
352            .layers
353            .iter()
354            .map(|name| {
355                json!({
356                    "id": name,
357                    "fields": {},
358                    "minzoom": self.min_zoom,
359                    "maxzoom": self.max_zoom,
360                })
361            })
362            .collect();
363
364        // STAC temporal extent: interval is an array of [start, end] pairs with
365        // `null` for an open end. We carry the bucket size + ISO-8601 step and
366        // any LOD pyramid as additive keys.
367        let mut temporal = json!({
368            "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
369            "bucket_ms": self.temporal_bucket_ms,
370        });
371        if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
372            temporal["step"] = json!(step);
373        }
374        if let Some(levels) = &self.temporal_lod {
375            temporal["lod"] = json!(levels
376                .iter()
377                .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
378                .collect::<Vec<_>>());
379        }
380
381        json!({
382            "tilejson": "3.0.0",
383            "tiles": [tiles],
384            "name": self.name,
385            "description": self.description,
386            "attribution": self.attribution,
387            "scheme": "xyz",
388            "version": "1.0.0",
389            "minzoom": self.min_zoom,
390            "maxzoom": self.max_zoom,
391            "bounds": [
392                self.bounds.min_lon,
393                self.bounds.min_lat,
394                self.bounds.max_lon,
395                self.bounds.max_lat
396            ],
397            "center": [center_lon, center_lat, self.min_zoom],
398            "vector_layers": vector_layers,
399            "temporal": temporal,
400        })
401    }
402}
403
404/// Verify the LOD invariants: ascending bucket order, multiples of the base
405/// bucket, strictly coarser than base, distinct bucket sizes.
406fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
407    if base_bucket_ms == 0 {
408        return Err(Error::Other(
409            "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
410        ));
411    }
412    let mut prev: Option<u64> = None;
413    for (i, level) in levels.iter().enumerate() {
414        if level.bucket_ms == 0 {
415            return Err(Error::Other(format!(
416                "temporal_lod[{i}].bucket_ms must be non-zero"
417            )));
418        }
419        if level.bucket_ms <= base_bucket_ms {
420            return Err(Error::Other(format!(
421                "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
422                level.bucket_ms, base_bucket_ms
423            )));
424        }
425        if level.bucket_ms % base_bucket_ms != 0 {
426            return Err(Error::Other(format!(
427                "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
428                level.bucket_ms, base_bucket_ms
429            )));
430        }
431        if let Some(p) = prev {
432            if level.bucket_ms <= p {
433                return Err(Error::Other(format!(
434                    "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
435                    level.bucket_ms, p
436                )));
437            }
438        }
439        prev = Some(level.bucket_ms);
440    }
441    Ok(())
442}
443
444/// Format a millisecond duration as an ISO-8601 duration string for the
445/// TileJSON `temporal.step` field (best-effort: days / hours / minutes /
446/// seconds). Returns `None` for a zero bucket.
447fn iso8601_duration(ms: u64) -> Option<String> {
448    if ms == 0 {
449        return None;
450    }
451    Some(if ms % 86_400_000 == 0 {
452        format!("P{}D", ms / 86_400_000)
453    } else if ms % 3_600_000 == 0 {
454        format!("PT{}H", ms / 3_600_000)
455    } else if ms % 60_000 == 0 {
456        format!("PT{}M", ms / 60_000)
457    } else if ms % 1000 == 0 {
458        format!("PT{}S", ms / 1000)
459    } else {
460        format!("PT{:.3}S", ms as f64 / 1000.0)
461    })
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_metadata_json_roundtrip() {
470        let metadata = Metadata::new("json-test")
471            .with_description("desc")
472            .with_zoom_levels(2, 12)
473            .with_temporal_bucket_ms(3_600_000)
474            .with_property("source", "unit-test");
475        let bytes = metadata.to_json_bytes().unwrap();
476        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
477        assert_eq!(decoded.name, "json-test");
478        assert_eq!(decoded.min_zoom, 2);
479        assert_eq!(decoded.max_zoom, 12);
480        assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
481        assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
482    }
483
484    #[test]
485    fn test_metadata_summary_tier_roundtrip() {
486        let tier = SummaryTier {
487            scheme: SummaryScheme::H3,
488            min_zoom: 0,
489            max_zoom: 4,
490            cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
491            columns: vec![
492                SummaryColumn {
493                    name: "magnitude".to_string(),
494                    agg: SummaryAggregation::Mean,
495                },
496                SummaryColumn {
497                    name: "magnitude".to_string(),
498                    agg: SummaryAggregation::Max,
499                },
500            ],
501            layer_name: "summary".to_string(),
502            sub_buckets: 1,
503        };
504        let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
505        let bytes = metadata.to_json_bytes().unwrap();
506        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
507        let dt = decoded.summary_tier.unwrap();
508        assert_eq!(dt.scheme, SummaryScheme::H3);
509        assert_eq!(dt.min_zoom, 0);
510        assert_eq!(dt.max_zoom, 4);
511        assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
512        assert_eq!(dt.columns.len(), 2);
513        assert_eq!(dt.resolution_for_zoom(2), 2);
514        // Out-of-range zooms clamp to the endpoints.
515        assert_eq!(dt.resolution_for_zoom(10), 4);
516    }
517
518    #[test]
519    fn test_metadata_ignores_unknown_fields() {
520        // Forward compat: metadata written by a newer builder (or carrying
521        // since-removed fields like the old `raster_tier` scaffold) must
522        // still decode — serde skips unknown keys by default.
523        let metadata = Metadata::new("fwd");
524        let mut v: serde_json::Value =
525            serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
526        v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
527        let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
528        assert_eq!(decoded.name, "fwd");
529    }
530
531    #[test]
532    fn test_heatmap_domain_roundtrip() {
533        let domain = HeatmapDomain {
534            classes: vec![
535                HeatmapClassDomain {
536                    id: "pickup".to_string(),
537                    min: 0.0,
538                    max: 7.5,
539                    property: Some("intensity".to_string()),
540                },
541                HeatmapClassDomain {
542                    id: "dropoff".to_string(),
543                    min: 0.0,
544                    max: 9.0,
545                    property: None,
546                },
547            ],
548        };
549        let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
550        let bytes = metadata.to_json_bytes().unwrap();
551        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
552        let d = decoded.heatmap_domain.unwrap();
553        assert_eq!(d.classes.len(), 2);
554        assert_eq!(d.classes[0].id, "pickup");
555        assert_eq!(d.classes[0].max, 7.5);
556        assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
557        assert_eq!(d.classes[1].id, "dropoff");
558        assert_eq!(d.classes[1].property, None);
559    }
560
561    #[test]
562    fn test_heatmap_domain_field_omitted_when_unset() {
563        let metadata = Metadata::new("no-heat");
564        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
565        assert!(!s.contains("heatmap_domain"), "got: {s}");
566    }
567
568    #[test]
569    fn test_metadata_without_summary_tier_decodes() {
570        // A pre-summary-tier archive's metadata JSON has no `summary_tier`
571        // field at all. serde's `#[default]` must accept it.
572        let json = br#"{
573            "name": "old",
574            "description": "",
575            "attribution": "",
576            "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
577            "time_range": {"start":0,"end":1000},
578            "min_zoom": 0,
579            "max_zoom": 8,
580            "tile_count": 0,
581            "feature_count": 0,
582            "layers": ["default"],
583            "properties": {},
584            "temporal_bucket_ms": 3600000
585        }"#;
586        let m = Metadata::from_json_bytes(json).unwrap();
587        assert!(m.summary_tier.is_none());
588    }
589
590    #[test]
591    fn test_metadata_builder() {
592        let metadata = Metadata::new("test")
593            .with_description("Test archive")
594            .with_attribution("Test data")
595            .with_zoom_levels(0, 14)
596            .with_property("key", "value");
597
598        assert_eq!(metadata.name, "test");
599        assert_eq!(metadata.description, "Test archive");
600        assert_eq!(metadata.min_zoom, 0);
601        assert_eq!(metadata.max_zoom, 14);
602        assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
603    }
604
605    // ------------------------------------------------------------------
606    // temporal_lod
607    // ------------------------------------------------------------------
608
609    fn hour() -> u64 {
610        3_600_000
611    }
612    fn day() -> u64 {
613        24 * hour()
614    }
615    fn thirty_days() -> u64 {
616        30 * day()
617    }
618
619    #[test]
620    fn temporal_lod_roundtrips_through_json() {
621        let levels = vec![
622            TemporalLodLevel {
623                bucket_ms: day(),
624                max_zoom_level: 8,
625            },
626            TemporalLodLevel {
627                bucket_ms: thirty_days(),
628                max_zoom_level: 4,
629            },
630        ];
631        let metadata = Metadata::new("lod")
632            .with_temporal_bucket_ms(hour())
633            .with_temporal_lod(levels.clone())
634            .unwrap();
635        let bytes = metadata.to_json_bytes().unwrap();
636        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
637        assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
638    }
639
640    #[test]
641    fn temporal_lod_field_omitted_when_unset() {
642        // Older readers that don't know about temporal_lod must still parse
643        // a freshly-written archive; the field is skipped when None.
644        let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
645        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
646        assert!(!s.contains("temporal_lod"), "got: {s}");
647    }
648
649    #[test]
650    fn temporal_lod_missing_field_decodes_back_compat() {
651        // A v3 archive built before this feature has no `temporal_lod` key
652        // in its metadata JSON; the new field must default to None.
653        let legacy = r#"{
654            "name": "legacy",
655            "description": "",
656            "attribution": "",
657            "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
658            "time_range": {"start": 0, "end": 1},
659            "min_zoom": 0,
660            "max_zoom": 14,
661            "tile_count": 0,
662            "feature_count": 0,
663            "layers": ["default"],
664            "properties": {},
665            "temporal_bucket_ms": 3600000
666        }"#;
667        let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
668        assert!(m.temporal_lod.is_none());
669    }
670
671    #[test]
672    fn temporal_lod_rejects_non_multiple_bucket() {
673        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
674            TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
675        ]);
676        assert!(res.is_err());
677    }
678
679    #[test]
680    fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
681        let res = Metadata::new("bad")
682            .with_temporal_bucket_ms(day())
683            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
684        assert!(res.is_err());
685
686        let res = Metadata::new("bad")
687            .with_temporal_bucket_ms(hour())
688            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
689        assert!(res.is_err());
690    }
691
692    #[test]
693    fn temporal_lod_rejects_unsorted_levels() {
694        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
695            TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
696            TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
697        ]);
698        assert!(res.is_err());
699    }
700
701    #[test]
702    fn temporal_lod_for_zoom_picks_coarsest_applicable() {
703        let m = Metadata::new("lod")
704            .with_temporal_bucket_ms(hour())
705            .with_temporal_lod(vec![
706                TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
707                TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
708            ])
709            .unwrap();
710        // Very-zoomed-out: both levels apply, pick the coarser (30d).
711        assert_eq!(
712            m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
713            Some(thirty_days())
714        );
715        // Mid zoom: only the day level applies.
716        assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
717        // High zoom: no LOD — fall back to base bucket.
718        assert!(m.temporal_lod_for_zoom(12).is_none());
719    }
720
721    #[test]
722    fn temporal_lod_for_zoom_is_none_when_unset() {
723        let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
724        assert!(m.temporal_lod_for_zoom(0).is_none());
725    }
726
727    #[test]
728    fn temporal_lod_empty_vec_clears_to_none() {
729        // Passing an empty list is treated as "no LOD" rather than an error,
730        // so callers can compute the level set unconditionally.
731        let m = Metadata::new("empty")
732            .with_temporal_bucket_ms(hour())
733            .with_temporal_lod(vec![])
734            .unwrap();
735        assert!(m.temporal_lod.is_none());
736    }
737
738    #[test]
739    fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
740        let m = Metadata::new("quakes")
741            .with_description("USGS earthquakes")
742            .with_attribution("USGS")
743            .with_zoom_levels(0, 10)
744            .with_temporal_bucket_ms(hour())
745            .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
746            .with_temporal_lod(vec![TemporalLodLevel {
747                bucket_ms: day(),
748                max_zoom_level: 6,
749            }])
750            .unwrap();
751        let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
752
753        // Core TileJSON 3.0 fields every web client recognises.
754        assert_eq!(tj["tilejson"], "3.0.0");
755        assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
756        assert_eq!(tj["minzoom"], 0);
757        assert_eq!(tj["maxzoom"], 10);
758        assert_eq!(tj["scheme"], "xyz");
759        assert_eq!(tj["vector_layers"][0]["id"], "default");
760        assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
761
762        // Additive STAC-style temporal extension.
763        assert_eq!(tj["temporal"]["step"], "PT1H");
764        assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
765        let interval = &tj["temporal"]["interval"][0];
766        assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
767        assert!(interval[1].is_string());
768        assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
769    }
770
771    #[test]
772    fn iso8601_duration_formats_common_buckets() {
773        assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
774        assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
775        assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
776        assert_eq!(iso8601_duration(0), None);
777    }
778
779    #[test]
780    fn tilejson_time_beyond_i64_is_null_not_garbage() {
781        // A timestamp past i64::MAX ms must surface as a null open bound rather
782        // than wrapping to a negative (bogus) date.
783        let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
784        let tj = m.to_tilejson(None);
785        assert!(tj["temporal"]["interval"][0][0].is_null());
786        assert!(tj["temporal"]["interval"][0][1].is_null());
787    }
788}