1use crate::error::{Result, StreamingError};
4use bytes::Bytes;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct TileCoordinate {
11 pub z: u8,
13
14 pub x: u32,
16
17 pub y: u32,
19}
20
21impl TileCoordinate {
22 pub fn new(z: u8, x: u32, y: u32) -> Self {
24 Self { z, x, y }
25 }
26
27 pub fn to_xyz_string(&self) -> String {
29 format!("{}/{}/{}", self.z, self.x, self.y)
30 }
31
32 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TileRequest {
89 pub coord: TileCoordinate,
91
92 pub format: TileFormat,
94
95 pub layer: Option<String>,
97
98 pub style: Option<String>,
100
101 pub params: std::collections::HashMap<String, String>,
103}
104
105impl TileRequest {
106 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 pub fn with_layer(mut self, layer: String) -> Self {
119 self.layer = Some(layer);
120 self
121 }
122
123 pub fn with_style(mut self, style: String) -> Self {
125 self.style = Some(style);
126 self
127 }
128
129 pub fn with_param(mut self, key: String, value: String) -> Self {
131 self.params.insert(key, value);
132 self
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct TileResponse {
139 pub coord: TileCoordinate,
141
142 pub data: Bytes,
144
145 pub content_type: String,
147
148 pub cache_control: Option<String>,
150
151 pub etag: Option<String>,
153
154 pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
156}
157
158impl TileResponse {
159 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 pub fn with_cache_control(mut self, cache_control: String) -> Self {
173 self.cache_control = Some(cache_control);
174 self
175 }
176
177 pub fn with_etag(mut self, etag: String) -> Self {
179 self.etag = Some(etag);
180 self
181 }
182
183 pub fn size_bytes(&self) -> usize {
185 self.data.len()
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub enum TileFormat {
192 Png,
194
195 Jpeg,
197
198 WebP,
200
201 Pbf,
203
204 GeoJson,
206
207 Json,
209}
210
211impl TileFormat {
212 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 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#[async_trait::async_trait]
245pub trait TileProtocol: Send + Sync {
246 async fn get_tile(&self, request: &TileRequest) -> Result<TileResponse>;
248
249 async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool>;
251
252 async fn get_tile_metadata(&self, coord: &TileCoordinate) -> Result<TileMetadata>;
254
255 fn zoom_levels(&self) -> (u8, u8);
257
258 fn tile_size(&self) -> (u32, u32);
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct TileMetadata {
265 pub coord: TileCoordinate,
267
268 pub size_bytes: usize,
270
271 pub format: TileFormat,
273
274 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
276
277 pub modified_at: Option<chrono::DateTime<chrono::Utc>>,
279
280 pub bbox: Option<oxigeo_core::types::BoundingBox>,
282
283 pub metadata: std::collections::HashMap<String, String>,
285}
286
287#[cfg(feature = "tile-http")]
293const HTTP_MAX_ATTEMPTS: u32 = 3;
294
295#[cfg(feature = "tile-http")]
297const HTTP_INITIAL_DELAY_MS: u64 = 100;
298
299#[cfg(feature = "tile-http")]
301fn parse_http_date(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
302 if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(value) {
304 return Some(dt.with_timezone(&chrono::Utc));
305 }
306 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
309 return Some(dt.with_timezone(&chrono::Utc));
310 }
311 None
312}
313
314pub struct XyzProtocol {
320 url_template: String,
322
323 min_zoom: u8,
325
326 max_zoom: u8,
328
329 tile_size: (u32, u32),
331
332 #[cfg(feature = "tile-http")]
334 http_client: reqwest::Client,
335}
336
337impl XyzProtocol {
338 #[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 #[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 pub fn with_tile_size(mut self, width: u32, height: u32) -> Self {
363 self.tile_size = (width, height);
364 self
365 }
366
367 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 #[cfg(feature = "tile-http")]
377 fn build_url_with_ext(&self, coord: &TileCoordinate, ext: &str) -> String {
378 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 #[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 #[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); let mut last_err: Option<StreamingError> = None;
405
406 for attempt in 0..HTTP_MAX_ATTEMPTS {
407 if attempt > 0 {
408 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 if status == reqwest::StatusCode::NOT_FOUND {
425 return Err(StreamingError::TileNotFound);
426 }
427
428 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 if !status.is_success() {
439 return Err(StreamingError::HttpError {
440 status: status.as_u16(),
441 url: url.to_owned(),
442 });
443 }
444
445 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 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 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 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 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 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 tile_resp.coord = request.coord;
520 return Ok(tile_resp);
521 }
522
523 #[cfg(not(feature = "tile-http"))]
524 {
525 let _ = request;
529 Err(StreamingError::FeatureNotEnabled("tile-http".to_string()))
530 }
531 }
532
533 async fn has_tile(&self, coord: &TileCoordinate) -> Result<bool> {
534 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 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 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 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 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 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
648pub struct TmsProtocol {
650 inner: XyzProtocol,
651}
652
653impl TmsProtocol {
654 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 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
693pub 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 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 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 pub fn with_tile_size(mut self, width: u32, height: u32) -> Self {
731 self.tile_size = (width, height);
732 self
733 }
734
735 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); }
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 let err = proto.get_tile(&request).await.expect_err("should error");
952 assert!(matches!(err, StreamingError::FeatureNotEnabled(_)));
953 let err = proto.has_tile(&coord).await.expect_err("should error");
955 assert!(matches!(err, StreamingError::FeatureNotEnabled(_)));
956 let oor = TileCoordinate::new(19, 0, 0);
958 assert!(!proto.has_tile(&oor).await.expect("out of range ok"));
959 }
960}