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}