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#[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}