Skip to main content

trailgen_data/
providers.rs

1use crate::{MAX_REGION_DEG2, MAX_SOURCE_BYTES, SurveyRegion, provider_client};
2use anyhow::{Context as _, Result, bail, ensure};
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use serde_json::{Map, Value, json};
5use std::{env, fmt, str::FromStr, time::Duration};
6use trailgen_core::{ContextOverlay, SegmentDraft, io::geojson, source::GeoBounds};
7
8pub const DEFAULT_USGS_TRAILS_ENDPOINT: &str =
9    "https://cartowfs.nationalmap.gov/arcgis/rest/services/transportation/MapServer/8/query";
10pub const DEFAULT_NY_STATE_PARKS_ENDPOINT: &str = "https://services.arcgis.com/1xFZPtKn1wKC6POA/arcgis/rest/services/NY_State_Parks_Trails/FeatureServer/0/query";
11pub const DEFAULT_TEXAS_STATE_PARKS_ENDPOINT: &str =
12    "https://tpwd.texas.gov/arcgis/rest/services/Parks/TexasStateParksTrails/MapServer/0/query";
13const USGS_PAGE_SIZE: usize = 2_000;
14const AUTHORITY_PAGE_SIZE: usize = 2_000;
15const NEW_YORK_BOUNDS: GeoBounds = GeoBounds::new(-79.77, 40.47, -71.75, 45.02);
16const TEXAS_BOUNDS: GeoBounds = GeoBounds::new(-106.66, 25.83, -93.50, 36.51);
17const NY_LICENSE: &str = "NYS OPRHP informational and non-commercial use; attribution required";
18const TEXAS_LICENSE: &str = "TPWD public trail data; informational use; attribution TPWD|SP|NR|PGR";
19
20#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
21pub struct ProviderId(String);
22
23impl ProviderId {
24    pub fn new(raw: impl Into<String>) -> Result<Self> {
25        let raw = raw.into();
26        ensure!(
27            !raw.is_empty()
28                && raw.len() <= 64
29                && raw
30                    .bytes()
31                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
32            "provider id must contain only lowercase ASCII letters, digits, or hyphens"
33        );
34        Ok(Self(raw))
35    }
36
37    #[must_use]
38    pub fn as_str(&self) -> &str {
39        &self.0
40    }
41}
42
43impl fmt::Display for ProviderId {
44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45        formatter.write_str(&self.0)
46    }
47}
48
49impl FromStr for ProviderId {
50    type Err = anyhow::Error;
51
52    fn from_str(raw: &str) -> Result<Self> {
53        Self::new(raw)
54    }
55}
56
57impl Serialize for ProviderId {
58    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
59    where
60        S: Serializer,
61    {
62        serializer.serialize_str(&self.0)
63    }
64}
65
66impl<'de> Deserialize<'de> for ProviderId {
67    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
68    where
69        D: Deserializer<'de>,
70    {
71        let raw = String::deserialize(deserializer)?;
72        Self::new(raw).map_err(serde::de::Error::custom)
73    }
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct ProviderDescriptor {
78    pub id: ProviderId,
79    pub label: &'static str,
80    pub adapter_revision: u16,
81    pub precedence: u16,
82    pub extension: &'static str,
83    pub request_extension: &'static str,
84}
85
86#[derive(Clone, Debug)]
87pub struct ProviderPayload {
88    pub bytes: Vec<u8>,
89    pub request: String,
90    pub origin: String,
91}
92
93#[derive(Clone, Copy)]
94pub struct RawShard<'a> {
95    pub region: &'a SurveyRegion,
96    pub bytes: &'a [u8],
97}
98
99#[derive(Default)]
100pub struct NormalizedNetwork {
101    pub drafts: Vec<SegmentDraft>,
102    pub context: Vec<ContextOverlay>,
103}
104
105pub trait NetworkProvider {
106    fn descriptor(&self) -> ProviderDescriptor;
107    fn covers(&self, _bounds: GeoBounds) -> bool {
108        true
109    }
110    fn acquire(&self, bounds: trailgen_core::source::GeoBounds) -> Result<ProviderPayload>;
111    fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork>;
112}
113
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115enum Authority {
116    NewYork,
117    Texas,
118}
119
120impl Authority {
121    fn descriptor(self) -> ProviderDescriptor {
122        let (id, label) = match self {
123            Self::NewYork => ("ny-state-parks", "New York State Parks"),
124            Self::Texas => ("texas-state-parks", "Texas State Parks"),
125        };
126        ProviderDescriptor {
127            id: ProviderId::new(id).expect("static provider id is valid"),
128            label,
129            adapter_revision: 1,
130            precedence: 0,
131            extension: "geojson",
132            request_extension: "request",
133        }
134    }
135
136    const fn bounds(self) -> GeoBounds {
137        match self {
138            Self::NewYork => NEW_YORK_BOUNDS,
139            Self::Texas => TEXAS_BOUNDS,
140        }
141    }
142
143    const fn where_clause(self) -> &'static str {
144        match self {
145            Self::NewYork => "Public_='Y' AND Foot='Y' AND (Status IS NULL OR Status<>'Proposed')",
146            Self::Texas => "Official='Yes' AND TrailUse LIKE '%Hiking%'",
147        }
148    }
149
150    const fn out_fields(self) -> &'static str {
151        match self {
152            Self::NewYork => {
153                "OBJECTID,Unit,Facility,Asset,Sub_Asset,Name,Alt_Name,Abbreviation,Blaze,Blaze_2,Blaze_3,Map_Blaze,Map_Blaze_2,Public_,Status,Surface,Foot,Miles,MID,ParksApp,GlobalID"
154            }
155            Self::Texas => "OBJECTID,ParkName,Official,Name1,TrailUse,LengthMI,GlobalID",
156        }
157    }
158}
159
160/// A bounded, authority-owned `ArcGIS` trail service admitted into the automatic
161/// corpus with a provider-native normalization law.
162#[derive(Clone, Debug)]
163pub struct AuthorityTrailProvider {
164    authority: Authority,
165    endpoint: String,
166    timeout: Duration,
167}
168
169impl AuthorityTrailProvider {
170    #[must_use]
171    pub fn new_york() -> Self {
172        Self::new_york_at(
173            env::var("TRAILGEN_NY_STATE_PARKS_ENDPOINT")
174                .unwrap_or_else(|_| DEFAULT_NY_STATE_PARKS_ENDPOINT.to_owned()),
175            Duration::from_secs(90),
176        )
177    }
178
179    #[must_use]
180    pub fn texas() -> Self {
181        Self::texas_at(
182            env::var("TRAILGEN_TEXAS_STATE_PARKS_ENDPOINT")
183                .unwrap_or_else(|_| DEFAULT_TEXAS_STATE_PARKS_ENDPOINT.to_owned()),
184            Duration::from_secs(90),
185        )
186    }
187
188    #[must_use]
189    pub fn new_york_at(endpoint: impl Into<String>, timeout: Duration) -> Self {
190        Self {
191            authority: Authority::NewYork,
192            endpoint: endpoint.into(),
193            timeout,
194        }
195    }
196
197    #[must_use]
198    pub fn texas_at(endpoint: impl Into<String>, timeout: Duration) -> Self {
199        Self {
200            authority: Authority::Texas,
201            endpoint: endpoint.into(),
202            timeout,
203        }
204    }
205
206    fn fetch_page(
207        &self,
208        client: &reqwest::blocking::Client,
209        bounds: GeoBounds,
210        offset: usize,
211    ) -> Result<Value> {
212        let descriptor = self.descriptor();
213        let page = client
214            .get(&self.endpoint)
215            .query(&authority_query(self.authority, bounds, offset))
216            .send()
217            .with_context(|| format!("query {} through {}", descriptor.label, self.endpoint))?
218            .error_for_status()
219            .with_context(|| format!("{} endpoint returned an HTTP error", descriptor.label))?
220            .json::<Value>()
221            .with_context(|| format!("decode {} GeoJSON", descriptor.label))?;
222        if let Some(error) = page.get("error") {
223            bail!("{} endpoint rejected the query: {error}", descriptor.label);
224        }
225        Ok(page)
226    }
227}
228
229impl NetworkProvider for AuthorityTrailProvider {
230    fn descriptor(&self) -> ProviderDescriptor {
231        self.authority.descriptor()
232    }
233
234    fn covers(&self, bounds: GeoBounds) -> bool {
235        intersects(self.authority.bounds(), bounds)
236    }
237
238    fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
239        ensure!(bounds.is_valid(), "invalid authority trail-data bounds");
240        let area = (bounds.east - bounds.west) * (bounds.north - bounds.south);
241        ensure!(
242            area <= MAX_REGION_DEG2,
243            "authority trail-data bounds span {area:.2} square degrees; limit is {MAX_REGION_DEG2:.2}"
244        );
245        if !self.covers(bounds) {
246            return Ok(ProviderPayload {
247                bytes: empty_feature_collection()?,
248                request: format!(
249                    "outside {} coverage; bbox={},{},{},{}",
250                    self.descriptor().label,
251                    bounds.west,
252                    bounds.south,
253                    bounds.east,
254                    bounds.north
255                ),
256                origin: self.endpoint.clone(),
257            });
258        }
259        let client = provider_client(self.descriptor().id.as_str(), self.timeout)
260            .with_context(|| format!("build {} client", self.descriptor().label))?;
261        let mut features = Vec::new();
262        let mut encoded_feature_bytes = 0_u64;
263        for offset in (0..).step_by(AUTHORITY_PAGE_SIZE) {
264            let page = self.fetch_page(&client, bounds, offset)?;
265            let mut page_features = page
266                .get("features")
267                .and_then(Value::as_array)
268                .cloned()
269                .with_context(|| {
270                    format!(
271                        "{} response is not a GeoJSON FeatureCollection",
272                        self.descriptor().label
273                    )
274                })?;
275            let page_len = page_features.len();
276            encoded_feature_bytes = encoded_feature_bytes
277                .checked_add(serde_json::to_vec(&page_features)?.len() as u64)
278                .context("authority trail response size overflow")?;
279            ensure!(
280                encoded_feature_bytes <= MAX_SOURCE_BYTES,
281                "{} response exceeds {} MiB",
282                self.descriptor().label,
283                MAX_SOURCE_BYTES / 1_048_576
284            );
285            features.append(&mut page_features);
286            if page_len < AUTHORITY_PAGE_SIZE {
287                break;
288            }
289        }
290        let bytes = serde_json::to_vec(&json!({
291            "type": "FeatureCollection",
292            "features": features,
293        }))?;
294        ensure!(
295            bytes.len() as u64 <= MAX_SOURCE_BYTES,
296            "{} response exceeds {} MiB",
297            self.descriptor().label,
298            MAX_SOURCE_BYTES / 1_048_576
299        );
300        Ok(ProviderPayload {
301            bytes,
302            request: format!(
303                "bbox={},{},{},{}; where={}; out_fields={}; page_size={AUTHORITY_PAGE_SIZE}",
304                bounds.west,
305                bounds.south,
306                bounds.east,
307                bounds.north,
308                self.authority.where_clause(),
309                self.authority.out_fields()
310            ),
311            origin: self.endpoint.clone(),
312        })
313    }
314
315    fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
316        let mut drafts = Vec::new();
317        for shard in shards {
318            let mut root = serde_json::from_slice::<Value>(shard.bytes).with_context(|| {
319                format!("parse sequestered {} GeoJSON", self.descriptor().label)
320            })?;
321            let features = root
322                .get_mut("features")
323                .and_then(Value::as_array_mut)
324                .with_context(|| {
325                    format!(
326                        "{} receipt is not a GeoJSON FeatureCollection",
327                        self.descriptor().label
328                    )
329                })?;
330            let mut admitted = Vec::with_capacity(features.len());
331            for mut feature in std::mem::take(features) {
332                let keep = match self.authority {
333                    Authority::NewYork => normalize_new_york(&mut feature)?,
334                    Authority::Texas => normalize_texas(&mut feature)?,
335                };
336                if keep {
337                    admitted.push(feature);
338                }
339            }
340            *features = admitted;
341            drafts.extend(
342                geojson::network_from_str(&serde_json::to_string(&root)?)
343                    .with_context(|| format!("normalize {} geometry", self.descriptor().label))?,
344            );
345        }
346        Ok(NormalizedNetwork {
347            drafts,
348            context: Vec::new(),
349        })
350    }
351}
352
353#[derive(Clone, Debug)]
354pub struct UsgsNationalTrails {
355    endpoint: String,
356    timeout: Duration,
357}
358
359impl Default for UsgsNationalTrails {
360    fn default() -> Self {
361        Self {
362            endpoint: env::var("TRAILGEN_USGS_TRAILS_ENDPOINT")
363                .unwrap_or_else(|_| DEFAULT_USGS_TRAILS_ENDPOINT.to_owned()),
364            timeout: Duration::from_secs(90),
365        }
366    }
367}
368
369impl UsgsNationalTrails {
370    #[must_use]
371    pub fn new(endpoint: impl Into<String>, timeout: Duration) -> Self {
372        Self {
373            endpoint: endpoint.into(),
374            timeout,
375        }
376    }
377
378    fn fetch_page(
379        &self,
380        client: &reqwest::blocking::Client,
381        bounds: trailgen_core::source::GeoBounds,
382        offset: usize,
383    ) -> Result<Value> {
384        client
385            .get(&self.endpoint)
386            .query(&usgs_query(bounds, offset))
387            .send()
388            .with_context(|| {
389                format!(
390                    "query USGS National Digital Trails through {}",
391                    self.endpoint
392                )
393            })?
394            .error_for_status()
395            .with_context(|| {
396                format!(
397                    "USGS National Digital Trails endpoint {} returned an error",
398                    self.endpoint
399                )
400            })?
401            .json()
402            .context("decode USGS National Digital Trails GeoJSON")
403    }
404}
405
406impl NetworkProvider for UsgsNationalTrails {
407    fn descriptor(&self) -> ProviderDescriptor {
408        ProviderDescriptor {
409            id: ProviderId::new("usgs-national-trails").expect("static provider id is valid"),
410            label: "USGS National Digital Trails",
411            adapter_revision: 1,
412            precedence: 20,
413            extension: "geojson",
414            request_extension: "request",
415        }
416    }
417
418    fn acquire(&self, bounds: trailgen_core::source::GeoBounds) -> Result<ProviderPayload> {
419        let area = (bounds.east - bounds.west) * (bounds.north - bounds.south);
420        ensure!(bounds.is_valid(), "invalid USGS trail-data bounds");
421        ensure!(
422            area <= MAX_REGION_DEG2,
423            "USGS trail-data bounds span {area:.2} square degrees; limit is {MAX_REGION_DEG2:.2}"
424        );
425        let client = provider_client("usgs-trail-source", self.timeout)
426            .context("build USGS National Digital Trails client")?;
427        let mut features = Vec::new();
428        let mut encoded_feature_bytes = 0_u64;
429        for offset in (0..).step_by(USGS_PAGE_SIZE) {
430            let page = self.fetch_page(&client, bounds, offset)?;
431            let mut page_features = page
432                .get("features")
433                .and_then(Value::as_array)
434                .cloned()
435                .context("USGS response is not a GeoJSON FeatureCollection")?;
436            let page_len = page_features.len();
437            encoded_feature_bytes = encoded_feature_bytes
438                .checked_add(serde_json::to_vec(&page_features)?.len() as u64)
439                .context("USGS trail response size overflow")?;
440            ensure!(
441                encoded_feature_bytes <= MAX_SOURCE_BYTES,
442                "USGS trail response exceeds {} MiB",
443                MAX_SOURCE_BYTES / 1_048_576
444            );
445            features.append(&mut page_features);
446            if page_len < USGS_PAGE_SIZE {
447                break;
448            }
449        }
450        let bytes = serde_json::to_vec(&json!({
451            "type": "FeatureCollection",
452            "features": features,
453        }))?;
454        ensure!(
455            bytes.len() as u64 <= MAX_SOURCE_BYTES,
456            "USGS trail response exceeds {} MiB",
457            MAX_SOURCE_BYTES / 1_048_576
458        );
459        Ok(ProviderPayload {
460            bytes,
461            request: format!(
462                "bbox={},{},{},{}; where=trailtype='Terra Trail' and hikerpedestrian='Y'; page_size={USGS_PAGE_SIZE}",
463                bounds.west, bounds.south, bounds.east, bounds.north
464            ),
465            origin: self.endpoint.clone(),
466        })
467    }
468
469    fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
470        let mut drafts = Vec::new();
471        for shard in shards {
472            let mut root = serde_json::from_slice::<Value>(shard.bytes)
473                .context("parse sequestered USGS trail GeoJSON")?;
474            let features = root
475                .get_mut("features")
476                .and_then(Value::as_array_mut)
477                .context("USGS receipt is not a GeoJSON FeatureCollection")?;
478            for feature in features {
479                normalize_usgs_properties(feature)?;
480            }
481            drafts.extend(
482                geojson::network_from_str(&serde_json::to_string(&root)?)
483                    .context("normalize USGS trail geometry")?,
484            );
485        }
486        Ok(NormalizedNetwork {
487            drafts,
488            context: Vec::new(),
489        })
490    }
491}
492
493fn usgs_query(
494    bounds: trailgen_core::source::GeoBounds,
495    offset: usize,
496) -> Vec<(&'static str, String)> {
497    vec![
498        ("f", "geojson".to_owned()),
499        (
500            "where",
501            "trailtype='Terra Trail' AND hikerpedestrian='Y'".to_owned(),
502        ),
503        (
504            "geometry",
505            format!(
506                "{},{},{},{}",
507                bounds.west, bounds.south, bounds.east, bounds.north
508            ),
509        ),
510        ("geometryType", "esriGeometryEnvelope".to_owned()),
511        ("inSR", "4326".to_owned()),
512        ("spatialRel", "esriSpatialRelIntersects".to_owned()),
513        (
514            "outFields",
515            "objectid,permanentidentifier,name,namealternate,trailnumber,sourcefeatureid,sourcedatasetid,sourceoriginator,publisheddate,sourceeditdate,trailsurface,routetype,trailtype,hikerpedestrian".to_owned(),
516        ),
517        ("outSR", "4326".to_owned()),
518        ("returnGeometry", "true".to_owned()),
519        ("orderByFields", "objectid".to_owned()),
520        ("resultOffset", offset.to_string()),
521        ("resultRecordCount", USGS_PAGE_SIZE.to_string()),
522    ]
523}
524
525fn normalize_usgs_properties(feature: &mut Value) -> Result<()> {
526    let properties = feature
527        .get_mut("properties")
528        .and_then(Value::as_object_mut)
529        .context("USGS feature has no properties")?;
530    let id = string_property(properties, "permanentidentifier")
531        .or_else(|| string_property(properties, "sourcefeatureid"))
532        .or_else(|| properties.get("objectid").map(Value::to_string));
533    let originator = string_property(properties, "sourceoriginator");
534    let dataset = string_property(properties, "sourcedatasetid");
535    let layer = match (originator, dataset) {
536        (Some(originator), Some(dataset)) => Some(format!("{originator} · {dataset}")),
537        (originator, dataset) => originator.or(dataset),
538    };
539    let surface = string_property(properties, "trailsurface");
540    properties.insert("source".to_owned(), json!("usgs-national-trails"));
541    properties.insert("license".to_owned(), json!("USGS public domain"));
542    properties.insert("way_kind".to_owned(), json!("path"));
543    properties.insert("trail_standing".to_owned(), json!("established"));
544    properties.insert("terrain".to_owned(), json!("trail"));
545    properties.insert("access".to_owned(), json!("unknown"));
546    properties.insert("confidence".to_owned(), json!(0.86));
547    if let Some(id) = id {
548        properties.insert("id".to_owned(), Value::String(id));
549    }
550    if let Some(layer) = layer {
551        properties.insert("layer".to_owned(), Value::String(layer));
552    }
553    if let Some(surface) = surface {
554        properties.insert("surface".to_owned(), Value::String(surface));
555    }
556    Ok(())
557}
558
559fn authority_query(
560    authority: Authority,
561    bounds: GeoBounds,
562    offset: usize,
563) -> Vec<(&'static str, String)> {
564    vec![
565        ("f", "geojson".to_owned()),
566        ("where", authority.where_clause().to_owned()),
567        (
568            "geometry",
569            format!(
570                "{},{},{},{}",
571                bounds.west, bounds.south, bounds.east, bounds.north
572            ),
573        ),
574        ("geometryType", "esriGeometryEnvelope".to_owned()),
575        ("inSR", "4326".to_owned()),
576        ("spatialRel", "esriSpatialRelIntersects".to_owned()),
577        ("outFields", authority.out_fields().to_owned()),
578        ("outSR", "4326".to_owned()),
579        ("returnGeometry", "true".to_owned()),
580        ("orderByFields", "OBJECTID".to_owned()),
581        ("resultOffset", offset.to_string()),
582        ("resultRecordCount", AUTHORITY_PAGE_SIZE.to_string()),
583    ]
584}
585
586fn normalize_new_york(feature: &mut Value) -> Result<bool> {
587    let properties = feature
588        .get_mut("properties")
589        .and_then(Value::as_object_mut)
590        .context("New York State Parks feature has no properties")?;
591    if !property_is(properties, "Public_", "Y")
592        || !property_is(properties, "Foot", "Y")
593        || property_is(properties, "Status", "Proposed")
594    {
595        return Ok(false);
596    }
597    let Some(asset) = properties.get("Asset").and_then(Value::as_i64) else {
598        return Ok(false);
599    };
600    let (class, terrain, marking, road_exposure) = match asset {
601        0 => ("path", "trail", "unmarked", 0.0),
602        1 => ("path", "trail", "marked", 0.0),
603        2 => ("track", "road", "unknown", 0.85),
604        3 => ("road", "road", "unknown", 1.0),
605        4 => ("footway", "pavement", "unknown", 0.0),
606        _ => return Ok(false),
607    };
608    let access = match string_property(properties, "Status")
609        .as_deref()
610        .map(str::to_ascii_lowercase)
611        .as_deref()
612    {
613        Some("open") => "open",
614        Some("closed") => "closed",
615        _ => "unknown",
616    };
617    let id = string_property(properties, "MID")
618        .or_else(|| string_property(properties, "GlobalID"))
619        .or_else(|| properties.get("OBJECTID").map(Value::to_string));
620    let layer = joined_properties(properties, &["Facility", "Unit"]);
621    let surface = string_property(properties, "Surface");
622
623    properties.insert("source".to_owned(), json!("ny-state-parks"));
624    properties.insert("license".to_owned(), json!(NY_LICENSE));
625    properties.insert("way_kind".to_owned(), json!(class));
626    properties.insert("trail_standing".to_owned(), json!("established"));
627    properties.insert("trail_marking".to_owned(), json!(marking));
628    properties.insert("terrain".to_owned(), json!(terrain));
629    properties.insert("access".to_owned(), json!(access));
630    properties.insert("road_exposure".to_owned(), json!(road_exposure));
631    properties.insert("confidence".to_owned(), json!(0.97));
632    if let Some(id) = id {
633        properties.insert("id".to_owned(), Value::String(id));
634    }
635    if let Some(layer) = layer {
636        properties.insert("layer".to_owned(), Value::String(layer));
637    }
638    if let Some(surface) = surface {
639        properties.insert("surface".to_owned(), Value::String(surface));
640    }
641    Ok(true)
642}
643
644fn normalize_texas(feature: &mut Value) -> Result<bool> {
645    let properties = feature
646        .get_mut("properties")
647        .and_then(Value::as_object_mut)
648        .context("Texas State Parks feature has no properties")?;
649    let hiking = string_property(properties, "TrailUse")
650        .is_some_and(|uses| uses.to_ascii_lowercase().contains("hiking"));
651    if !property_is(properties, "Official", "Yes") || !hiking {
652        return Ok(false);
653    }
654    let id = string_property(properties, "GlobalID")
655        .or_else(|| properties.get("OBJECTID").map(Value::to_string));
656    let layer = string_property(properties, "ParkName");
657
658    properties.insert("source".to_owned(), json!("texas-state-parks"));
659    properties.insert("license".to_owned(), json!(TEXAS_LICENSE));
660    properties.insert("way_kind".to_owned(), json!("path"));
661    properties.insert("trail_standing".to_owned(), json!("established"));
662    properties.insert("trail_marking".to_owned(), json!("unknown"));
663    properties.insert("terrain".to_owned(), json!("trail"));
664    properties.insert("access".to_owned(), json!("unknown"));
665    properties.insert("road_exposure".to_owned(), json!(0.0));
666    properties.insert("confidence".to_owned(), json!(0.95));
667    if let Some(id) = id {
668        properties.insert("id".to_owned(), Value::String(id));
669    }
670    if let Some(layer) = layer {
671        properties.insert("layer".to_owned(), Value::String(layer));
672    }
673    Ok(true)
674}
675
676fn empty_feature_collection() -> Result<Vec<u8>> {
677    serde_json::to_vec(&json!({
678        "type": "FeatureCollection",
679        "features": [],
680    }))
681    .context("encode empty authority receipt")
682}
683
684fn intersects(left: GeoBounds, right: GeoBounds) -> bool {
685    left.west < right.east
686        && right.west < left.east
687        && left.south < right.north
688        && right.south < left.north
689}
690
691fn property_is(properties: &Map<String, Value>, key: &str, expected: &str) -> bool {
692    string_property(properties, key).is_some_and(|value| value.eq_ignore_ascii_case(expected))
693}
694
695fn joined_properties(properties: &Map<String, Value>, keys: &[&str]) -> Option<String> {
696    let fields = keys
697        .iter()
698        .filter_map(|key| string_property(properties, key))
699        .collect::<Vec<_>>();
700    (!fields.is_empty()).then(|| fields.join(" · "))
701}
702
703fn string_property(properties: &Map<String, Value>, key: &str) -> Option<String> {
704    properties
705        .get(key)
706        .and_then(Value::as_str)
707        .filter(|value| !value.trim().is_empty())
708        .map(str::to_owned)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn state_authorities_do_not_contact_foreign_rectangles() -> Result<()> {
717        let texas =
718            AuthorityTrailProvider::texas_at("http://127.0.0.1:1/never", Duration::from_millis(1));
719        let new_york = GeoBounds::new(-74.2, 41.1, -74.0, 41.3);
720
721        assert!(!texas.covers(new_york));
722        let payload = texas.acquire(new_york)?;
723        let root = serde_json::from_slice::<Value>(&payload.bytes)?;
724        assert_eq!(
725            root.get("features").and_then(Value::as_array).map(Vec::len),
726            Some(0)
727        );
728        Ok(())
729    }
730}