Skip to main content

oxigeo_streaming/tile/
protocol.rs

1//! Tile streaming protocol implementations.
2
3use crate::error::{Result, StreamingError};
4use bytes::Bytes;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// Tile coordinate in a tile matrix.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct TileCoordinate {
11    /// Zoom level
12    pub z: u8,
13
14    /// Column (x) index
15    pub x: u32,
16
17    /// Row (y) index
18    pub y: u32,
19}
20
21impl TileCoordinate {
22    /// Create a new tile coordinate.
23    pub fn new(z: u8, x: u32, y: u32) -> Self {
24        Self { z, x, y }
25    }
26
27    /// Convert to XYZ format string.
28    pub fn to_xyz_string(&self) -> String {
29        format!("{}/{}/{}", self.z, self.x, self.y)
30    }
31
32    /// Convert to TMS format (flipped Y).
33    pub fn to_tms(&self) -> Self {
34        let max_y = (1u32 << self.z) - 1;
35        Self {
36            z: self.z,
37            x: self.x,
38            y: max_y - self.y,
39        }
40    }
41
42    /// Get parent tile coordinate.
43    pub fn parent(&self) -> Option<Self> {
44        if self.z == 0 {
45            return None;
46        }
47        Some(Self {
48            z: self.z - 1,
49            x: self.x / 2,
50            y: self.y / 2,
51        })
52    }
53
54    /// Get child tile coordinates.
55    pub fn children(&self) -> Vec<Self> {
56        if self.z >= 31 {
57            return vec![];
58        }
59        let z = self.z + 1;
60        let x = self.x * 2;
61        let y = self.y * 2;
62        vec![
63            Self::new(z, x, y),
64            Self::new(z, x + 1, y),
65            Self::new(z, x, y + 1),
66            Self::new(z, x + 1, y + 1),
67        ]
68    }
69
70    /// Check if this tile is a valid coordinate for the given zoom level.
71    pub fn is_valid(&self) -> bool {
72        if self.z > 31 {
73            return false;
74        }
75        let max_coord = 1u32 << self.z;
76        self.x < max_coord && self.y < max_coord
77    }
78}
79
80impl fmt::Display for TileCoordinate {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "{}/{}/{}", self.z, self.x, self.y)
83    }
84}
85
86/// Tile request parameters.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TileRequest {
89    /// Tile coordinate
90    pub coord: TileCoordinate,
91
92    /// Tile format (png, jpg, webp, pbf, etc.)
93    pub format: TileFormat,
94
95    /// Optional layer name
96    pub layer: Option<String>,
97
98    /// Optional style name
99    pub style: Option<String>,
100
101    /// Additional parameters
102    pub params: std::collections::HashMap<String, String>,
103}
104
105impl TileRequest {
106    /// Create a new tile request.
107    pub fn new(coord: TileCoordinate, format: TileFormat) -> Self {
108        Self {
109            coord,
110            format,
111            layer: None,
112            style: None,
113            params: std::collections::HashMap::new(),
114        }
115    }
116
117    /// Set the layer name.
118    pub fn with_layer(mut self, layer: String) -> Self {
119        self.layer = Some(layer);
120        self
121    }
122
123    /// Set the style name.
124    pub fn with_style(mut self, style: String) -> Self {
125        self.style = Some(style);
126        self
127    }
128
129    /// Add a parameter.
130    pub fn with_param(mut self, key: String, value: String) -> Self {
131        self.params.insert(key, value);
132        self
133    }
134}
135
136/// Tile response.
137#[derive(Debug, Clone)]
138pub struct TileResponse {
139    /// Tile coordinate
140    pub coord: TileCoordinate,
141
142    /// Tile data
143    pub data: Bytes,
144
145    /// Content type
146    pub content_type: String,
147
148    /// Cache control headers
149    pub cache_control: Option<String>,
150
151    /// ETag for cache validation
152    pub etag: Option<String>,
153
154    /// Last modified timestamp
155    pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
156}
157
158impl TileResponse {
159    /// Create a new tile response.
160    pub fn new(coord: TileCoordinate, data: Bytes, content_type: String) -> Self {
161        Self {
162            coord,
163            data,
164            content_type,
165            cache_control: None,
166            etag: None,
167            last_modified: None,
168        }
169    }
170
171    /// Set cache control.
172    pub fn with_cache_control(mut self, cache_control: String) -> Self {
173        self.cache_control = Some(cache_control);
174        self
175    }
176
177    /// Set ETag.
178    pub fn with_etag(mut self, etag: String) -> Self {
179        self.etag = Some(etag);
180        self
181    }
182
183    /// Get the size in bytes.
184    pub fn size_bytes(&self) -> usize {
185        self.data.len()
186    }
187}
188
189/// Tile format.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub enum TileFormat {
192    /// PNG format
193    Png,
194
195    /// JPEG format
196    Jpeg,
197
198    /// WebP format
199    WebP,
200
201    /// Protocol Buffer (vector tiles)
202    Pbf,
203
204    /// GeoJSON
205    GeoJson,
206
207    /// JSON
208    Json,
209}
210
211impl TileFormat {
212    /// Get the MIME type for this format.
213    pub fn mime_type(&self) -> &'static str {
214        match self {
215            TileFormat::Png => "image/png",
216            TileFormat::Jpeg => "image/jpeg",
217            TileFormat::WebP => "image/webp",
218            TileFormat::Pbf => "application/x-protobuf",
219            TileFormat::GeoJson => "application/geo+json",
220            TileFormat::Json => "application/json",
221        }
222    }
223
224    /// Get the file extension for this format.
225    pub fn extension(&self) -> &'static str {
226        match self {
227            TileFormat::Png => "png",
228            TileFormat::Jpeg => "jpg",
229            TileFormat::WebP => "webp",
230            TileFormat::Pbf => "pbf",
231            TileFormat::GeoJson => "geojson",
232            TileFormat::Json => "json",
233        }
234    }
235}
236
237impl fmt::Display for TileFormat {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(f, "{}", self.extension())
240    }
241}
242
243/// Tile protocol interface.
244#[async_trait::async_trait]
245pub trait TileProtocol: Send + Sync {
246    /// Get a tile.
247    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse>;
248
249    /// Check if a tile exists.
250    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool>;
251
252    /// Get tile metadata.
253    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata>;
254
255    /// Get the supported zoom levels.
256    fn zoom_levels(&self) -> (u8, u8);
257
258    /// Get the tile size in pixels.
259    fn tile_size(&self) -> (u32, u32);
260}
261
262/// Tile metadata.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct TileMetadata {
265    /// Tile coordinate
266    pub coord: TileCoordinate,
267
268    /// Size in bytes
269    pub size_bytes: usize,
270
271    /// Format
272    pub format: TileFormat,
273
274    /// Creation timestamp
275    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
276
277    /// Last modified timestamp
278    pub modified_at: Option<chrono::DateTime<chrono::Utc>>,
279
280    /// Bounding box
281    pub bbox: Option<oxigeo_core::types::BoundingBox>,
282
283    /// Additional metadata
284    pub metadata: std::collections::HashMap<String, String>,
285}
286
287// ─────────────────────────────────────────────────────────────────────────────
288// HTTP retry helper (feature = "tile-http")
289// ─────────────────────────────────────────────────────────────────────────────
290
291/// Maximum number of retry attempts for transient 5xx errors.
292#[cfg(feature = "tile-http")]
293const HTTP_MAX_ATTEMPTS: u32 = 3;
294
295/// Initial backoff delay in milliseconds before the first retry.
296#[cfg(feature = "tile-http")]
297const HTTP_INITIAL_DELAY_MS: u64 = 100;
298
299/// Parse an RFC-2616 / RFC-7231 `Last-Modified` header value into a UTC datetime.
300#[cfg(feature = "tile-http")]
301fn parse_http_date(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
302    // Try RFC 2822 (most common in HTTP/1.1)
303    if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(value) {
304        return Some(dt.with_timezone(&chrono::Utc));
305    }
306    // Try the older ANSI-C asctime format: "Tue, 15 Nov 1994 08:12:31 GMT"
307    // also covered by rfc2822 above. Try ISO 8601 as a fallback.
308    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
309        return Some(dt.with_timezone(&chrono::Utc));
310    }
311    None
312}
313
314// ─────────────────────────────────────────────────────────────────────────────
315// XyzProtocol
316// ─────────────────────────────────────────────────────────────────────────────
317
318/// XYZ tile protocol implementation.
319pub struct XyzProtocol {
320    /// Base URL template (`{z}`, `{x}`, `{y}` are replaced at fetch time)
321    url_template: String,
322
323    /// Minimum zoom level
324    min_zoom: u8,
325
326    /// Maximum zoom level
327    max_zoom: u8,
328
329    /// Tile size
330    tile_size: (u32, u32),
331
332    /// Reusable HTTP client (only compiled when tile-http is active)
333    #[cfg(feature = "tile-http")]
334    http_client: reqwest::Client,
335}
336
337impl XyzProtocol {
338    /// Create a new XYZ protocol.
339    #[cfg(not(feature = "tile-http"))]
340    pub fn new(url_template: String, min_zoom: u8, max_zoom: u8) -> Self {
341        Self {
342            url_template,
343            min_zoom,
344            max_zoom,
345            tile_size: (256, 256),
346        }
347    }
348
349    /// Create a new XYZ protocol with an HTTP client.
350    #[cfg(feature = "tile-http")]
351    pub fn new(url_template: String, min_zoom: u8, max_zoom: u8) -> Self {
352        Self {
353            url_template,
354            min_zoom,
355            max_zoom,
356            tile_size: (256, 256),
357            http_client: reqwest::Client::new(),
358        }
359    }
360
361    /// Set the tile size.
362    pub fn with_tile_size(mut self, width: u32, height: u32) -> Self {
363        self.tile_size = (width, height);
364        self
365    }
366
367    /// Build URL for a tile.
368    pub fn build_url(&self, coord: &TileCoordinate) -> String {
369        self.url_template
370            .replace("{z}", &coord.z.to_string())
371            .replace("{x}", &coord.x.to_string())
372            .replace("{y}", &coord.y.to_string())
373    }
374
375    /// Build URL for a tile with an explicit file extension.
376    #[cfg(feature = "tile-http")]
377    fn build_url_with_ext(&self, coord: &TileCoordinate, ext: &str) -> String {
378        // If the template already contains `{ext}` expand it; otherwise fall
379        // back to the numeric placeholders only.
380        self.url_template
381            .replace("{ext}", ext)
382            .replace("{z}", &coord.z.to_string())
383            .replace("{x}", &coord.x.to_string())
384            .replace("{y}", &coord.y.to_string())
385    }
386
387    /// Perform a single HTTP GET and return `(status, headers, body)`.
388    #[cfg(feature = "tile-http")]
389    async fn http_get_raw(
390        &self,
391        url: &str,
392    ) -> std::result::Result<reqwest::Response, reqwest::Error> {
393        self.http_client.get(url).send().await
394    }
395
396    /// Fetch the tile bytes with automatic retry on transient 5xx errors.
397    ///
398    /// Returns `(data_bytes, etag, last_modified, cache_control, content_type)`.
399    #[cfg(feature = "tile-http")]
400    async fn fetch_with_retry(&self, url: &str, expected_mime: &str) -> Result<TileResponse> {
401        use std::time::Duration;
402
403        let coord_placeholder = TileCoordinate::new(0, 0, 0); // only used for the error path
404        let mut last_err: Option<StreamingError> = None;
405
406        for attempt in 0..HTTP_MAX_ATTEMPTS {
407            if attempt > 0 {
408                // Simple exponential back-off: 100 ms, 200 ms, …
409                let delay_ms = HTTP_INITIAL_DELAY_MS * (1u64 << (attempt - 1));
410                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
411            }
412
413            let response = match self.http_get_raw(url).await {
414                Ok(r) => r,
415                Err(e) => {
416                    last_err = Some(StreamingError::Reqwest(e));
417                    continue;
418                }
419            };
420
421            let status = response.status();
422
423            // 404 → tile does not exist; do not retry
424            if status == reqwest::StatusCode::NOT_FOUND {
425                return Err(StreamingError::TileNotFound);
426            }
427
428            // 5xx → transient; retry
429            if status.is_server_error() {
430                last_err = Some(StreamingError::HttpError {
431                    status: status.as_u16(),
432                    url: url.to_owned(),
433                });
434                continue;
435            }
436
437            // Any other non-2xx → permanent client-side error; fail immediately
438            if !status.is_success() {
439                return Err(StreamingError::HttpError {
440                    status: status.as_u16(),
441                    url: url.to_owned(),
442                });
443            }
444
445            // --- 2xx: extract metadata from headers before consuming body ---
446
447            // ETag
448            let etag = response
449                .headers()
450                .get(reqwest::header::ETAG)
451                .and_then(|v| v.to_str().ok())
452                .map(|s| s.to_owned());
453
454            // Last-Modified
455            let last_modified = response
456                .headers()
457                .get(reqwest::header::LAST_MODIFIED)
458                .and_then(|v| v.to_str().ok())
459                .and_then(parse_http_date);
460
461            // Cache-Control
462            let cache_control = response
463                .headers()
464                .get(reqwest::header::CACHE_CONTROL)
465                .and_then(|v| v.to_str().ok())
466                .map(|s| s.to_owned());
467
468            // Content-Type: prefer what the server says; fall back to expected_mime
469            let content_type = response
470                .headers()
471                .get(reqwest::header::CONTENT_TYPE)
472                .and_then(|v| v.to_str().ok())
473                .map(|s| s.to_owned())
474                .unwrap_or_else(|| expected_mime.to_owned());
475
476            // Consume body
477            let data = response.bytes().await.map_err(StreamingError::Reqwest)?;
478
479            let mut tile_resp = TileResponse::new(coord_placeholder, data, content_type);
480
481            if let Some(e) = etag {
482                tile_resp = tile_resp.with_etag(e);
483            }
484            if let Some(cc) = cache_control {
485                tile_resp = tile_resp.with_cache_control(cc);
486            }
487            tile_resp.last_modified = last_modified;
488
489            return Ok(tile_resp);
490        }
491
492        // All attempts exhausted
493        Err(last_err.unwrap_or_else(|| StreamingError::HttpError {
494            status: 500,
495            url: url.to_owned(),
496        }))
497    }
498}
499
500#[async_trait::async_trait]
501impl TileProtocol for XyzProtocol {
502    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
503        if request.coord.z < self.min_zoom || request.coord.z > self.max_zoom {
504            return Err(StreamingError::InvalidOperation(format!(
505                "Zoom level {} out of range [{}, {}]",
506                request.coord.z, self.min_zoom, self.max_zoom
507            )));
508        }
509
510        #[cfg(feature = "tile-http")]
511        {
512            let ext = request.format.extension();
513            let url = self.build_url_with_ext(&request.coord, ext);
514            let expected_mime = request.format.mime_type();
515
516            let mut tile_resp = self.fetch_with_retry(&url, expected_mime).await?;
517            // Stamp the actual requested coordinate (the placeholder in fetch_with_retry
518            // is only used before we know which coord is being requested).
519            tile_resp.coord = request.coord;
520            return Ok(tile_resp);
521        }
522
523        #[cfg(not(feature = "tile-http"))]
524        {
525            // Without the `tile-http` feature there is no HTTP transport, so we
526            // cannot fetch a remote tile. Return an honest typed error instead of
527            // a fake empty-but-successful response.
528            let _ = request;
529            Err(StreamingError::FeatureNotEnabled("tile-http".to_string()))
530        }
531    }
532
533    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool> {
534        // A coordinate outside the declared zoom range or otherwise invalid can
535        // be rejected without any network access.
536        if coord.z < self.min_zoom || coord.z > self.max_zoom || !coord.is_valid() {
537            return Ok(false);
538        }
539
540        #[cfg(feature = "tile-http")]
541        {
542            // Real existence check: issue a request and interpret the status.
543            // Many tile CDNs do not implement HEAD, so we use a lightweight GET
544            // and only inspect the status code (the body is discarded).
545            let url = self.build_url(coord);
546            let response = self
547                .http_get_raw(&url)
548                .await
549                .map_err(StreamingError::Reqwest)?;
550            let status = response.status();
551            if status == reqwest::StatusCode::NOT_FOUND {
552                return Ok(false);
553            }
554            if status.is_success() {
555                return Ok(true);
556            }
557            Err(StreamingError::HttpError {
558                status: status.as_u16(),
559                url,
560            })
561        }
562
563        #[cfg(not(feature = "tile-http"))]
564        {
565            // The coordinate is in range, but without HTTP transport we cannot
566            // verify that the tile actually exists on the remote. Returning
567            // `Ok(true)` here would be a fabricated answer, so surface an honest
568            // typed error instead.
569            Err(StreamingError::FeatureNotEnabled("tile-http".to_string()))
570        }
571    }
572
573    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata> {
574        #[cfg(feature = "tile-http")]
575        {
576            // Perform a HEAD-like request (GET is the safest fallback since many
577            // tile CDNs do not implement HEAD).  We use a PNG request by default.
578            let url = self.build_url(coord);
579            let response = self
580                .http_get_raw(&url)
581                .await
582                .map_err(StreamingError::Reqwest)?;
583
584            let status = response.status();
585            if status == reqwest::StatusCode::NOT_FOUND {
586                return Err(StreamingError::TileNotFound);
587            }
588            if !status.is_success() {
589                return Err(StreamingError::HttpError {
590                    status: status.as_u16(),
591                    url: url.clone(),
592                });
593            }
594
595            // Extract Last-Modified and ETag from headers.
596            let modified_at = response
597                .headers()
598                .get(reqwest::header::LAST_MODIFIED)
599                .and_then(|v| v.to_str().ok())
600                .and_then(parse_http_date);
601
602            let size_bytes = response
603                .headers()
604                .get(reqwest::header::CONTENT_LENGTH)
605                .and_then(|v| v.to_str().ok())
606                .and_then(|s| s.parse::<usize>().ok())
607                .unwrap_or(0);
608
609            let mut metadata_map = std::collections::HashMap::new();
610            if let Some(etag) = response
611                .headers()
612                .get(reqwest::header::ETAG)
613                .and_then(|v| v.to_str().ok())
614            {
615                metadata_map.insert("etag".to_owned(), etag.to_owned());
616            }
617
618            return Ok(TileMetadata {
619                coord: *coord,
620                size_bytes,
621                format: TileFormat::Png,
622                created_at: None,
623                modified_at,
624                bbox: None,
625                metadata: metadata_map,
626            });
627        }
628
629        #[cfg(not(feature = "tile-http"))]
630        {
631            // No HTTP transport → we cannot obtain real metadata (size,
632            // last-modified, etag). Returning zeroed metadata would fabricate
633            // data, so return an honest typed error.
634            let _ = coord;
635            Err(StreamingError::FeatureNotEnabled("tile-http".to_string()))
636        }
637    }
638
639    fn zoom_levels(&self) -> (u8, u8) {
640        (self.min_zoom, self.max_zoom)
641    }
642
643    fn tile_size(&self) -> (u32, u32) {
644        self.tile_size
645    }
646}
647
648/// TMS (Tile Map Service) protocol implementation.
649pub struct TmsProtocol {
650    inner: XyzProtocol,
651}
652
653impl TmsProtocol {
654    /// Create a new TMS protocol.
655    pub fn new(url_template: String, min_zoom: u8, max_zoom: u8) -> Self {
656        Self {
657            inner: XyzProtocol::new(url_template, min_zoom, max_zoom),
658        }
659    }
660}
661
662#[async_trait::async_trait]
663impl TileProtocol for TmsProtocol {
664    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
665        // Convert to TMS coordinates (flip Y)
666        let tms_coord = request.coord.to_tms();
667        let tms_request = TileRequest {
668            coord: tms_coord,
669            ..request.clone()
670        };
671        self.inner.get_tile(&tms_request).await
672    }
673
674    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool> {
675        let tms_coord = coord.to_tms();
676        self.inner.has_tile(&tms_coord).await
677    }
678
679    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata> {
680        let tms_coord = coord.to_tms();
681        self.inner.get_tile_metadata(&tms_coord).await
682    }
683
684    fn zoom_levels(&self) -> (u8, u8) {
685        self.inner.zoom_levels()
686    }
687
688    fn tile_size(&self) -> (u32, u32) {
689        self.inner.tile_size()
690    }
691}
692
693/// Local file-system tile protocol.
694///
695/// Reads tiles from disk laid out as `base_path/{z}/{x}/{y}.{ext}`, where the
696/// extension is derived from the requested [`TileFormat`]. This is a real,
697/// Pure-Rust protocol with no network dependency — usable directly or as the
698/// backing store for [`super::provider::TileSource::FileSystem`].
699pub struct FileSystemTileProtocol {
700    base_path: std::path::PathBuf,
701    format: TileFormat,
702    min_zoom: u8,
703    max_zoom: u8,
704    tile_size: (u32, u32),
705}
706
707impl FileSystemTileProtocol {
708    /// Create a new file-system tile protocol rooted at `base_path`.
709    ///
710    /// The default supported zoom range is `0..=31`; restrict it with
711    /// [`Self::with_zoom_levels`].
712    pub fn new(base_path: impl Into<std::path::PathBuf>, format: TileFormat) -> Self {
713        Self {
714            base_path: base_path.into(),
715            format,
716            min_zoom: 0,
717            max_zoom: 31,
718            tile_size: (256, 256),
719        }
720    }
721
722    /// Restrict the supported zoom range.
723    pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
724        self.min_zoom = min_zoom;
725        self.max_zoom = max_zoom;
726        self
727    }
728
729    /// Set the tile size in pixels.
730    pub fn with_tile_size(mut self, width: u32, height: u32) -> Self {
731        self.tile_size = (width, height);
732        self
733    }
734
735    /// On-disk path for a tile: `base_path/{z}/{x}/{y}.{ext}`.
736    pub fn tile_path(&self, coord: &TileCoordinate) -> std::path::PathBuf {
737        self.base_path.join(format!(
738            "{}/{}/{}.{}",
739            coord.z,
740            coord.x,
741            coord.y,
742            self.format.extension()
743        ))
744    }
745
746    fn check_zoom(&self, coord: &TileCoordinate) -> Result<()> {
747        if coord.z < self.min_zoom || coord.z > self.max_zoom {
748            return Err(StreamingError::InvalidOperation(format!(
749                "Zoom level {} out of range [{}, {}]",
750                coord.z, self.min_zoom, self.max_zoom
751            )));
752        }
753        Ok(())
754    }
755}
756
757#[async_trait::async_trait]
758impl TileProtocol for FileSystemTileProtocol {
759    async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse> {
760        self.check_zoom(&request.coord)?;
761        let path = self.tile_path(&request.coord);
762        match tokio::fs::read(&path).await {
763            Ok(data) => Ok(TileResponse::new(
764                request.coord,
765                Bytes::from(data),
766                self.format.mime_type().to_string(),
767            )),
768            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StreamingError::TileNotFound),
769            Err(e) => Err(StreamingError::Io(e)),
770        }
771    }
772
773    async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool> {
774        if coord.z < self.min_zoom || coord.z > self.max_zoom || !coord.is_valid() {
775            return Ok(false);
776        }
777        let path = self.tile_path(coord);
778        match tokio::fs::metadata(&path).await {
779            Ok(meta) => Ok(meta.is_file()),
780            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
781            Err(e) => Err(StreamingError::Io(e)),
782        }
783    }
784
785    async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata> {
786        self.check_zoom(coord)?;
787        let path = self.tile_path(coord);
788        let meta = match tokio::fs::metadata(&path).await {
789            Ok(m) => m,
790            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
791                return Err(StreamingError::TileNotFound);
792            }
793            Err(e) => return Err(StreamingError::Io(e)),
794        };
795
796        let modified_at = meta
797            .modified()
798            .ok()
799            .map(chrono::DateTime::<chrono::Utc>::from);
800
801        Ok(TileMetadata {
802            coord: *coord,
803            size_bytes: meta.len() as usize,
804            format: self.format,
805            created_at: meta
806                .created()
807                .ok()
808                .map(chrono::DateTime::<chrono::Utc>::from),
809            modified_at,
810            bbox: None,
811            metadata: std::collections::HashMap::new(),
812        })
813    }
814
815    fn zoom_levels(&self) -> (u8, u8) {
816        (self.min_zoom, self.max_zoom)
817    }
818
819    fn tile_size(&self) -> (u32, u32) {
820        self.tile_size
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    #[test]
829    fn test_tile_coordinate() {
830        let coord = TileCoordinate::new(10, 512, 384);
831        assert_eq!(coord.z, 10);
832        assert_eq!(coord.x, 512);
833        assert_eq!(coord.y, 384);
834        assert!(coord.is_valid());
835    }
836
837    #[test]
838    fn test_tile_parent() {
839        let coord = TileCoordinate::new(10, 512, 384);
840        let parent = coord.parent();
841        assert!(parent.is_some());
842        let parent = parent.expect("parent tile should exist for non-zero zoom level");
843        assert_eq!(parent.z, 9);
844        assert_eq!(parent.x, 256);
845        assert_eq!(parent.y, 192);
846    }
847
848    #[test]
849    fn test_tile_children() {
850        let coord = TileCoordinate::new(10, 512, 384);
851        let children = coord.children();
852        assert_eq!(children.len(), 4);
853        assert_eq!(children[0], TileCoordinate::new(11, 1024, 768));
854        assert_eq!(children[1], TileCoordinate::new(11, 1025, 768));
855        assert_eq!(children[2], TileCoordinate::new(11, 1024, 769));
856        assert_eq!(children[3], TileCoordinate::new(11, 1025, 769));
857    }
858
859    #[test]
860    fn test_tms_conversion() {
861        let coord = TileCoordinate::new(10, 512, 384);
862        let tms = coord.to_tms();
863        assert_eq!(tms.z, 10);
864        assert_eq!(tms.x, 512);
865        assert_eq!(tms.y, 639); // 1024 - 384 - 1
866    }
867
868    #[test]
869    fn test_tile_format() {
870        assert_eq!(TileFormat::Png.mime_type(), "image/png");
871        assert_eq!(TileFormat::Jpeg.extension(), "jpg");
872        assert_eq!(TileFormat::WebP.to_string(), "webp");
873    }
874
875    #[test]
876    fn test_build_url() {
877        let proto = XyzProtocol::new(
878            "https://tiles.example.com/{z}/{x}/{y}.png".to_string(),
879            0,
880            18,
881        );
882        let coord = TileCoordinate::new(10, 512, 384);
883        let url = proto.build_url(&coord);
884        assert_eq!(url, "https://tiles.example.com/10/512/384.png");
885    }
886
887    #[cfg(feature = "tile-http")]
888    #[test]
889    fn test_build_url_with_ext() {
890        let proto = XyzProtocol::new(
891            "https://tiles.example.com/{z}/{x}/{y}.{ext}".to_string(),
892            0,
893            18,
894        );
895        let coord = TileCoordinate::new(7, 63, 42);
896        let url = proto.build_url_with_ext(&coord, "pbf");
897        assert_eq!(url, "https://tiles.example.com/7/63/42.pbf");
898    }
899
900    #[test]
901    fn test_fs_tile_path_layout() {
902        let proto = FileSystemTileProtocol::new("/tiles", TileFormat::Jpeg);
903        let coord = TileCoordinate::new(5, 10, 15);
904        assert!(proto.tile_path(&coord).ends_with("5/10/15.jpg"));
905    }
906
907    #[tokio::test]
908    async fn test_fs_protocol_reads_real_tile() {
909        let dir = tempfile::tempdir().expect("temp dir");
910        let coord = TileCoordinate::new(3, 4, 5);
911        let tile_file = dir.path().join("3/4/5.png");
912        tokio::fs::create_dir_all(tile_file.parent().expect("parent"))
913            .await
914            .expect("mkdir");
915        tokio::fs::write(&tile_file, b"REAL_PNG_BYTES")
916            .await
917            .expect("write tile");
918
919        let proto = FileSystemTileProtocol::new(dir.path(), TileFormat::Png);
920        let request = TileRequest::new(coord, TileFormat::Png);
921        let resp = proto.get_tile(&request).await.expect("tile should read");
922        assert_eq!(&resp.data[..], b"REAL_PNG_BYTES");
923        assert_eq!(resp.content_type, "image/png");
924
925        assert!(proto.has_tile(&coord).await.expect("has_tile"));
926        let meta = proto.get_tile_metadata(&coord).await.expect("meta");
927        assert_eq!(meta.size_bytes, 14);
928    }
929
930    #[tokio::test]
931    async fn test_fs_protocol_missing_tile_is_not_found() {
932        let dir = tempfile::tempdir().expect("temp dir");
933        let proto = FileSystemTileProtocol::new(dir.path(), TileFormat::Png);
934        let coord = TileCoordinate::new(2, 1, 1);
935        let request = TileRequest::new(coord, TileFormat::Png);
936        let err = proto
937            .get_tile(&request)
938            .await
939            .expect_err("missing tile should error");
940        assert!(matches!(err, StreamingError::TileNotFound));
941        assert!(!proto.has_tile(&coord).await.expect("has_tile"));
942    }
943
944    #[cfg(not(feature = "tile-http"))]
945    #[tokio::test]
946    async fn test_xyz_without_http_feature_errors_honestly() {
947        let proto = XyzProtocol::new("https://example.com/{z}/{x}/{y}.png".to_string(), 0, 18);
948        let coord = TileCoordinate::new(5, 10, 15);
949        let request = TileRequest::new(coord, TileFormat::Png);
950        // No fake empty tile — an honest FeatureNotEnabled error instead.
951        let err = proto.get_tile(&request).await.expect_err("should error");
952        assert!(matches!(err, StreamingError::FeatureNotEnabled(_)));
953        // has_tile in range cannot be verified without HTTP → honest error.
954        let err = proto.has_tile(&coord).await.expect_err("should error");
955        assert!(matches!(err, StreamingError::FeatureNotEnabled(_)));
956        // Out-of-range is still answerable locally.
957        let oor = TileCoordinate::new(19, 0, 0);
958        assert!(!proto.has_tile(&oor).await.expect("out of range ok"));
959    }
960}