Skip to main content

oxigdal_mbtiles/
validation.rs

1//! MBTiles 1.3 metadata compliance validator.
2//!
3//! [`MBTilesMetadata::from_map`](crate::mbtiles::MBTilesMetadata::from_map)
4//! silently accepts any subset of `metadata` keys. This module adds a
5//! spec-compliance checker that reports violations of the
6//! [MBTiles 1.3 specification](https://github.com/mapbox/mbtiles-spec/blob/master/1.3/spec.md)
7//! as a list of [`ValidationIssue`]s.
8//!
9//! The structure mirrors the `GeoJsonValidator` in `oxigdal-geojson`: a
10//! validation-issue enum, an [`IssueSeverity`] classification, a human
11//! [`Display`](std::fmt::Display) rendering, and a set of small helper
12//! checks driven from a single entry point.
13//!
14//! ## Severity policy (MBTiles 1.3)
15//!
16//! * `name` and `format` are **required** — their absence is an
17//!   [`IssueSeverity::Error`].
18//! * `bounds`, `minzoom`, `maxzoom` are **recommended** — their absence is an
19//!   [`IssueSeverity::Warning`] (`bounds`) or [`IssueSeverity::Info`]
20//!   (zoom hints), never an error.
21//! * Values that are present but out of range / inconsistent are reported at
22//!   the severity defined per rule below.
23
24use std::fmt;
25
26use crate::mbtiles::MBTilesMetadata;
27use crate::tile_coords::TileFormat;
28use crate::writer::TileScheme;
29
30/// Classification of a [`ValidationIssue`].
31///
32/// `Error` marks a hard MBTiles 1.3 conformance violation; `Warning` marks a
33/// value that is suspect but tolerated by most readers; `Info` marks a purely
34/// advisory note (typically a missing *recommended* key).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum IssueSeverity {
37    /// A hard conformance violation — the archive is non-compliant.
38    Error,
39    /// A suspect value that most readers tolerate.
40    Warning,
41    /// An advisory note (e.g. a missing recommended key).
42    Info,
43}
44
45impl fmt::Display for IssueSeverity {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        let s = match self {
48            Self::Error => "error",
49            Self::Warning => "warning",
50            Self::Info => "info",
51        };
52        f.write_str(s)
53    }
54}
55
56/// A single MBTiles 1.3 metadata conformance finding.
57///
58/// Each variant carries enough context to render an actionable message via
59/// its [`Display`](std::fmt::Display) implementation.
60#[derive(Debug, Clone, PartialEq)]
61pub enum ValidationIssue {
62    /// A required (`name`/`format`) or recommended (`bounds`/`minzoom`/
63    /// `maxzoom`) metadata key was absent. The string is the key name.
64    MissingRequiredKey(String),
65    /// The `bounds` value is malformed or out of the WGS84 range. The string
66    /// describes the specific problem.
67    InvalidBounds(String),
68    /// The `center` value is malformed, out of range, or lies outside
69    /// `bounds`. The string describes the specific problem.
70    InvalidCenter(String),
71    /// A zoom value lies outside the supported `0..=30` range.
72    ZoomOutOfRange {
73        /// The offending zoom value (carried as `i64` so callers can surface
74        /// values that overflow the natural `u8` storage of a zoom level).
75        value: i64,
76    },
77    /// `minzoom` is strictly greater than `maxzoom`.
78    MinZoomGreaterThanMaxZoom {
79        /// The declared minimum zoom level.
80        minzoom: u8,
81        /// The declared maximum zoom level.
82        maxzoom: u8,
83    },
84    /// The `format` value is not one of the MBTiles 1.3 recognised formats
85    /// (`pbf`, `jpg`, `png`, `webp`). The string is the offending value.
86    UnknownFormat(String),
87    /// The `type` value is not one of `overlay` / `baselayer`. The string is
88    /// the offending value.
89    InvalidType(String),
90}
91
92impl ValidationIssue {
93    /// Return the [`IssueSeverity`] of this issue per the MBTiles 1.3 policy.
94    #[must_use]
95    pub fn severity(&self) -> IssueSeverity {
96        match self {
97            // Missing `name`/`format` are errors; missing recommended keys are
98            // softer. The distinction is encoded by the key name carried in the
99            // variant, so callers get the right severity without a second enum.
100            Self::MissingRequiredKey(key) => match key.as_str() {
101                "name" | "format" => IssueSeverity::Error,
102                "bounds" => IssueSeverity::Warning,
103                "minzoom" | "maxzoom" => IssueSeverity::Info,
104                _ => IssueSeverity::Warning,
105            },
106            Self::InvalidBounds(_) => IssueSeverity::Error,
107            Self::ZoomOutOfRange { .. } => IssueSeverity::Error,
108            Self::MinZoomGreaterThanMaxZoom { .. } => IssueSeverity::Error,
109            Self::InvalidCenter(_) => IssueSeverity::Warning,
110            Self::UnknownFormat(_) => IssueSeverity::Warning,
111            Self::InvalidType(_) => IssueSeverity::Warning,
112        }
113    }
114}
115
116impl fmt::Display for ValidationIssue {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::MissingRequiredKey(key) => {
120                write!(f, "missing metadata key `{key}`")
121            }
122            Self::InvalidBounds(detail) => {
123                write!(f, "invalid `bounds`: {detail}")
124            }
125            Self::InvalidCenter(detail) => {
126                write!(f, "invalid `center`: {detail}")
127            }
128            Self::ZoomOutOfRange { value } => {
129                write!(
130                    f,
131                    "zoom level {value} is outside the supported range 0..=30"
132                )
133            }
134            Self::MinZoomGreaterThanMaxZoom { minzoom, maxzoom } => {
135                write!(f, "minzoom ({minzoom}) is greater than maxzoom ({maxzoom})")
136            }
137            Self::UnknownFormat(value) => {
138                write!(
139                    f,
140                    "unrecognised `format` value `{value}` (expected one of pbf, jpg, png, webp)"
141                )
142            }
143            Self::InvalidType(value) => {
144                write!(
145                    f,
146                    "unrecognised `type` value `{value}` (expected `overlay` or `baselayer`)"
147                )
148            }
149        }
150    }
151}
152
153// ─── Geographic limits (WGS84) ─────────────────────────────────────────────────
154
155/// Maximum supported tile zoom level per MBTiles 1.3 (inclusive).
156const MAX_ZOOM: i64 = 30;
157/// Longitude bound magnitude (degrees).
158const LON_LIMIT: f64 = 180.0;
159/// Latitude bound magnitude (degrees).
160const LAT_LIMIT: f64 = 90.0;
161
162// ─── Entry point ───────────────────────────────────────────────────────────────
163
164/// Validate `metadata` against the MBTiles 1.3 spec, returning every issue
165/// found. The result is empty for fully compliant metadata.
166///
167/// `scheme` records the tile-coordinate convention the archive declares; the
168/// MBTiles 1.3 geographic checks (`bounds`/`center` ranges) are identical for
169/// both [`TileScheme::Tms`] and [`TileScheme::Xyz`], so it is accepted for API
170/// completeness and forward compatibility rather than to alter range limits.
171///
172/// Only `&self`-readable fields/accessors of [`MBTilesMetadata`] are consulted,
173/// so this function is unaffected by additional fields the struct may gain.
174#[must_use]
175pub fn validate_metadata(metadata: &MBTilesMetadata, scheme: TileScheme) -> Vec<ValidationIssue> {
176    // `scheme` does not change the WGS84 range limits below; bind it explicitly
177    // so the parameter is documented as intentionally range-neutral.
178    let _ = scheme;
179
180    let mut issues = Vec::new();
181
182    check_required_keys(metadata, &mut issues);
183    check_format(metadata, &mut issues);
184    check_type(metadata, &mut issues);
185    check_bounds(metadata, &mut issues);
186    check_zooms(metadata, &mut issues);
187    check_center(metadata, &mut issues);
188
189    issues
190}
191
192// ─── Individual rule checks ──────────────────────────────────────────────────────
193
194/// Required keys (`name`, `format`) and recommended keys (`bounds`, `minzoom`,
195/// `maxzoom`). Absence is reported via [`ValidationIssue::MissingRequiredKey`]
196/// whose severity is resolved from the key name.
197fn check_required_keys(metadata: &MBTilesMetadata, issues: &mut Vec<ValidationIssue>) {
198    if metadata.name.is_none() {
199        issues.push(ValidationIssue::MissingRequiredKey("name".to_string()));
200    }
201    if metadata.format.is_none() {
202        issues.push(ValidationIssue::MissingRequiredKey("format".to_string()));
203    }
204    if metadata.bounds.is_none() {
205        issues.push(ValidationIssue::MissingRequiredKey("bounds".to_string()));
206    }
207    if metadata.minzoom.is_none() {
208        issues.push(ValidationIssue::MissingRequiredKey("minzoom".to_string()));
209    }
210    if metadata.maxzoom.is_none() {
211        issues.push(ValidationIssue::MissingRequiredKey("maxzoom".to_string()));
212    }
213}
214
215/// `format` must be one of the MBTiles 1.3 recognised values. A present-but-
216/// unrecognised value parses to [`TileFormat::Unknown`]; a missing key is
217/// already reported by [`check_required_keys`], so it is skipped here.
218fn check_format(metadata: &MBTilesMetadata, issues: &mut Vec<ValidationIssue>) {
219    // A present-but-unrecognised `format` parses to `TileFormat::Unknown`,
220    // which carries the offending string. Recognised variants
221    // (png/jpg/png/webp/pbf) are spec-compliant and need no report. We test
222    // only for the `Unknown` bucket, which avoids an exhaustive `match` and
223    // therefore stays robust if `TileFormat` gains further variants. A missing
224    // `format` key is reported separately by `check_required_keys`.
225    if let Some(TileFormat::Unknown(value)) = &metadata.format {
226        issues.push(ValidationIssue::UnknownFormat(value.clone()));
227    }
228}
229
230/// `type`, when present, must be `overlay` or `baselayer`.
231fn check_type(metadata: &MBTilesMetadata, issues: &mut Vec<ValidationIssue>) {
232    if let Some(tile_type) = &metadata.tile_type {
233        let normalised = tile_type.trim().to_lowercase();
234        if normalised != "overlay" && normalised != "baselayer" {
235            issues.push(ValidationIssue::InvalidType(tile_type.clone()));
236        }
237    }
238}
239
240/// `bounds` is `minlon,minlat,maxlon,maxlat`. Each coordinate must sit within
241/// the WGS84 range and the minimum corner must be strictly south-west of the
242/// maximum corner.
243fn check_bounds(metadata: &MBTilesMetadata, issues: &mut Vec<ValidationIssue>) {
244    if let Some([min_lon, min_lat, max_lon, max_lat]) = bounds_of(metadata) {
245        if !min_lon.is_finite()
246            || !min_lat.is_finite()
247            || !max_lon.is_finite()
248            || !max_lat.is_finite()
249        {
250            issues.push(ValidationIssue::InvalidBounds(
251                "coordinates must be finite numbers".to_string(),
252            ));
253            return;
254        }
255        if min_lon.abs() > LON_LIMIT || max_lon.abs() > LON_LIMIT {
256            issues.push(ValidationIssue::InvalidBounds(format!(
257                "longitude out of [-180, 180]: min={min_lon}, max={max_lon}"
258            )));
259        }
260        if min_lat.abs() > LAT_LIMIT || max_lat.abs() > LAT_LIMIT {
261            issues.push(ValidationIssue::InvalidBounds(format!(
262                "latitude out of [-90, 90]: min={min_lat}, max={max_lat}"
263            )));
264        }
265        if min_lon >= max_lon {
266            issues.push(ValidationIssue::InvalidBounds(format!(
267                "minlon ({min_lon}) must be less than maxlon ({max_lon})"
268            )));
269        }
270        if min_lat >= max_lat {
271            issues.push(ValidationIssue::InvalidBounds(format!(
272                "minlat ({min_lat}) must be less than maxlat ({max_lat})"
273            )));
274        }
275    }
276}
277
278/// `minzoom`/`maxzoom` must each be within `0..=30` and `minzoom ≤ maxzoom`.
279fn check_zooms(metadata: &MBTilesMetadata, issues: &mut Vec<ValidationIssue>) {
280    if let Some(minzoom) = metadata.minzoom
281        && i64::from(minzoom) > MAX_ZOOM
282    {
283        issues.push(ValidationIssue::ZoomOutOfRange {
284            value: i64::from(minzoom),
285        });
286    }
287    if let Some(maxzoom) = metadata.maxzoom
288        && i64::from(maxzoom) > MAX_ZOOM
289    {
290        issues.push(ValidationIssue::ZoomOutOfRange {
291            value: i64::from(maxzoom),
292        });
293    }
294    if let (Some(minzoom), Some(maxzoom)) = (metadata.minzoom, metadata.maxzoom)
295        && minzoom > maxzoom
296    {
297        issues.push(ValidationIssue::MinZoomGreaterThanMaxZoom { minzoom, maxzoom });
298    }
299}
300
301/// `center` is `lon,lat,zoom`. The longitude/latitude must be in range, the
302/// zoom must be within `0..=30`, and the point must fall inside `bounds` when
303/// `bounds` is present and itself valid.
304fn check_center(metadata: &MBTilesMetadata, issues: &mut Vec<ValidationIssue>) {
305    let Some([lon, lat, zoom]) = center_of(metadata) else {
306        return;
307    };
308
309    if !lon.is_finite() || !lat.is_finite() || !zoom.is_finite() {
310        issues.push(ValidationIssue::InvalidCenter(
311            "values must be finite numbers".to_string(),
312        ));
313        return;
314    }
315    if lon.abs() > LON_LIMIT {
316        issues.push(ValidationIssue::InvalidCenter(format!(
317            "longitude {lon} out of [-180, 180]"
318        )));
319    }
320    if lat.abs() > LAT_LIMIT {
321        issues.push(ValidationIssue::InvalidCenter(format!(
322            "latitude {lat} out of [-90, 90]"
323        )));
324    }
325    if zoom < 0.0 || zoom > MAX_ZOOM as f64 {
326        issues.push(ValidationIssue::InvalidCenter(format!(
327            "zoom {zoom} is outside the supported range 0..=30"
328        )));
329    }
330
331    // Only cross-check against `bounds` when the bounds themselves are
332    // well-ordered, otherwise the comparison would be meaningless.
333    if let Some([min_lon, min_lat, max_lon, max_lat]) = bounds_of(metadata) {
334        let bounds_ordered = min_lon < max_lon && min_lat < max_lat;
335        if bounds_ordered && (lon < min_lon || lon > max_lon || lat < min_lat || lat > max_lat) {
336            issues.push(ValidationIssue::InvalidCenter(format!(
337                "center ({lon}, {lat}) lies outside bounds [{min_lon}, {min_lat}, {max_lon}, {max_lat}]"
338            )));
339        }
340    }
341}
342
343// ─── Field accessors ─────────────────────────────────────────────────────────────
344
345/// Read the parsed `bounds` array, if present.
346///
347/// Centralised so the rule checks never touch struct internals directly, which
348/// keeps them stable against additional fields on [`MBTilesMetadata`].
349fn bounds_of(metadata: &MBTilesMetadata) -> Option<[f64; 4]> {
350    metadata.bounds
351}
352
353/// Read the parsed `center` array, if present.
354fn center_of(metadata: &MBTilesMetadata) -> Option<[f64; 3]> {
355    metadata.center
356}