1mod providers;
4mod terrain;
5
6pub use providers::{
7 AuthorityTrailProvider, DEFAULT_NY_STATE_PARKS_ENDPOINT, DEFAULT_TEXAS_STATE_PARKS_ENDPOINT,
8 DEFAULT_USGS_TRAILS_ENDPOINT, NetworkProvider, NormalizedNetwork, ProviderDescriptor,
9 ProviderId, ProviderPayload, RawShard, UsgsNationalTrails,
10};
11pub use terrain::{TerrainTileId, TopographicTile};
12
13use anyhow::{Context as _, Result, ensure};
14use reqwest::blocking::Response;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest as _, Sha256};
17use std::{
18 cmp::Ordering,
19 collections::{BTreeMap, BTreeSet, BinaryHeap},
20 env,
21 fmt::Write as _,
22 fs::{self, File, OpenOptions},
23 io::{Read as _, Write as _},
24 path::{Path, PathBuf},
25 str::FromStr,
26 time::Duration,
27};
28use trailgen_core::{
29 Access, ContextOverlay, Coord, CrossingControl, CrossingKind, DEFAULT_SNAP_TOLERANCE_M, Edge,
30 EdgeTravel, EnrichmentConfig, GRAPH_CACHE, GeometryClaim, GraphBuilder, JunctionKey,
31 LineString, Provenance, SegmentDraft, Terrain, TrailMarking, TrailStanding, WalkGraph, WayKind,
32 WayRealm, apply_context_overlays, decode_graph, encode_graph,
33 io::{geojson, osm},
34 model::TerrainEvidence,
35 source::{
36 GeoBounds, SourceCandidate, SourceFingerprint, SourceKind, SourceManifest,
37 adapter_registry, discovery_recommendations, source_coverage,
38 },
39};
40
41pub const MIN_RADIUS_KM: f64 = 2.0;
42pub const DEFAULT_RADIUS_KM: f64 = 8.0;
43pub const MAX_RADIUS_KM: f64 = 40.0;
44pub const DEFAULT_NOMINATIM_ENDPOINT: &str = "https://nominatim.openstreetmap.org/search";
45pub const DEFAULT_OVERPASS_ENDPOINT: &str = "https://overpass-api.de/api/interpreter";
46pub const FALLBACK_OVERPASS_ENDPOINT: &str = "https://overpass.private.coffee/api/interpreter";
47pub const MAX_REGION_DEG2: f64 = 4.0;
48pub(crate) const MAX_SOURCE_BYTES: u64 = 256 * 1024 * 1024;
49const AUTOMATIC_OSM_PROFILE: OsmProfile = OsmProfile::All;
50const INDEX_SCHEMA: u8 = 17;
51const RAW_SCHEMA: u8 = 4;
52const MAX_OSM_CONNECTOR_M: f64 = 1_000.0;
53const LOCATION_CACHE: &str = "sources/location.json";
54const TRAIL_INDEX: &str = "cache/trails.json";
55const GRAPH_GEOJSON: &str = "cache/graph.geojson";
56const CONFLATION_REPORT: &str = "cache/conflation.json";
57const SOURCE_MANIFEST: &str = "sources/manifest.json";
58const GRAPH_AUXILIARIES: &[&str] = &[
59 "cache/graph.json",
60 GRAPH_GEOJSON,
61 "cache/edges.csv",
62 "cache/vertices.csv",
63];
64const OSM_TRAIL_SELECTORS: &[&str] = &[
65 r#"way["highway"~"^(path|footway|cycleway|pedestrian|track|steps|bridleway)$"]"#,
66 r#"way["disused:highway"~"^(path|footway|cycleway|track|pedestrian|steps|bridleway)$"]"#,
67 r#"way["abandoned:highway"~"^(path|footway|cycleway|track|pedestrian|steps|bridleway)$"]"#,
68 r#"way["route"~"^(hiking|foot|walking)$"]"#,
69];
70const OSM_ROAD_SELECTORS: &[&str] = &[
71 r#"way["highway"~"^(motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|service|track|road)$"]"#,
72];
73const OSM_HYDROLOGY_SELECTORS: &[&str] =
74 &[r#"way["waterway"~"^(stream|river|canal|drain|ditch|brook)$"]"#];
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum OsmProfile {
79 All,
80 Trails,
81 Roads,
82 Hydrology,
83}
84
85impl OsmProfile {
86 #[must_use]
87 pub const fn default_output(self) -> &'static str {
88 match self {
89 Self::All => "osm-extract.osm",
90 Self::Trails => "osm-trails.osm",
91 Self::Roads => "roads.osm",
92 Self::Hydrology => "hydrology.osm",
93 }
94 }
95
96 #[must_use]
97 pub const fn label(self) -> &'static str {
98 match self {
99 Self::All => "all",
100 Self::Trails => "trails",
101 Self::Roads => "roads",
102 Self::Hydrology => "hydrology",
103 }
104 }
105}
106
107impl FromStr for OsmProfile {
108 type Err = String;
109
110 fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
111 match raw {
112 "all" => Ok(Self::All),
113 "trails" => Ok(Self::Trails),
114 "roads" => Ok(Self::Roads),
115 "hydrology" => Ok(Self::Hydrology),
116 _ => Err("expected all, trails, roads, or hydrology".to_owned()),
117 }
118 }
119}
120
121#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
122pub struct Place {
123 pub query: String,
124 pub label: String,
125 pub center: Coord,
126 pub license: String,
127 pub provider: String,
128}
129
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132pub struct SurveyRegion {
133 pub id: String,
134 pub bounds: GeoBounds,
135}
136
137impl SurveyRegion {
138 pub fn new(bounds: GeoBounds) -> Result<Self> {
139 validate_region(bounds)?;
140 Ok(Self {
141 id: region_key(bounds),
142 bounds,
143 })
144 }
145
146 pub fn validate(&self) -> Result<()> {
147 validate_region(self.bounds)?;
148 ensure!(
149 self.id == region_key(self.bounds),
150 "survey-region id does not match its bounds"
151 );
152 Ok(())
153 }
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct Inventory {
158 pub trail_segments: usize,
159 pub road_features: usize,
160 pub waterway_features: usize,
161}
162
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164pub struct Summary {
165 pub regions: Vec<SurveyRegion>,
166 pub providers: Vec<ProviderId>,
167 pub inventory: Inventory,
168 pub vertices: usize,
169 pub edges: usize,
170 #[serde(default)]
171 pub elevation_tiles: usize,
172 pub raw_paths: Vec<PathBuf>,
173 pub conflation: trailgen_core::ConflationStats,
174 pub reused: bool,
175}
176
177#[derive(Clone, Debug)]
178pub struct Topography {
179 pub identity: String,
180 pub tiles: Vec<TopographicTile>,
181}
182
183pub fn indexed_topography_identity(project: &Path) -> Result<Option<String>> {
185 Ok(topographic_index(project)?.map(|index| topographic_identity(&index)))
186}
187
188pub fn indexed_topography(project: &Path) -> Result<Option<Topography>> {
190 let Some(index) = topographic_index(project)? else {
191 return Ok(None);
192 };
193 let identity = topographic_identity(&index);
194 let tiles = index
195 .elevation
196 .iter()
197 .map(|receipt| terrain::topographic_tile(project, receipt))
198 .collect::<Result<Vec<_>>>()?;
199 Ok(Some(Topography { identity, tiles }))
200}
201
202fn topographic_index(project: &Path) -> Result<Option<TrailIndex>> {
203 let path = project.join(TRAIL_INDEX);
204 let raw = match fs::read(&path) {
205 Ok(raw) => raw,
206 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
207 Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
208 };
209 let index: TrailIndex =
210 serde_json::from_slice(&raw).with_context(|| format!("parse {}", path.display()))?;
211 ensure!(
212 index.schema == INDEX_SCHEMA,
213 "trail index schema is obsolete"
214 );
215 Ok((!index.elevation.is_empty()).then_some(index))
216}
217
218fn topographic_identity(index: &TrailIndex) -> String {
219 let mut identity = Sha256::new();
220 for receipt in &index.elevation {
221 identity.update(receipt.tile.z.to_le_bytes());
222 identity.update(receipt.tile.x.to_le_bytes());
223 identity.update(receipt.tile.y.to_le_bytes());
224 identity.update(receipt.raw.sha256.as_bytes());
225 }
226 format!("{:x}", identity.finalize())
227}
228
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230#[serde(default)]
231pub struct TrailDataConfig {
232 pub managed: bool,
234 #[serde(skip_serializing_if = "Vec::is_empty")]
235 pub regions: Vec<SurveyRegion>,
236 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
238 pub region_names: BTreeMap<String, String>,
239 pub providers: Vec<ProviderId>,
240}
241
242impl Default for TrailDataConfig {
243 fn default() -> Self {
244 Self {
245 managed: false,
246 regions: Vec::new(),
247 region_names: BTreeMap::new(),
248 providers: automatic_provider_ids(),
249 }
250 }
251}
252
253fn automatic_provider_ids() -> Vec<ProviderId> {
254 [
255 "ny-state-parks",
256 "osm",
257 "texas-state-parks",
258 "usgs-national-trails",
259 ]
260 .map(|id| ProviderId::new(id).expect("static provider id is valid"))
261 .into_iter()
262 .collect()
263}
264
265fn legacy_automatic_provider_ids() -> Vec<ProviderId> {
266 ["osm", "usgs-national-trails"]
267 .map(|id| ProviderId::new(id).expect("static provider id is valid"))
268 .into_iter()
269 .collect()
270}
271
272#[derive(Clone, Debug)]
273pub enum Event {
274 Locating,
275 Located(Place),
276 Ranging {
277 provider: ProviderId,
278 region: SurveyRegion,
279 },
280 Downloaded {
281 provider: ProviderId,
282 bytes: u64,
283 },
284 Elevating {
285 complete: usize,
286 total: usize,
287 },
288 Indexing,
289 Ready(Summary),
290}
291
292impl Event {
293 #[must_use]
294 pub fn status(&self) -> String {
295 match self {
296 Self::Locating => "LOCATING US TRAIL AREA".to_owned(),
297 Self::Located(place) => format!("LOCATED · {}", place.label.to_ascii_uppercase()),
298 Self::Ranging { provider, region } => format!(
299 "FETCHING {provider} · {:.4}, {:.4} TO {:.4}, {:.4}",
300 region.bounds.west, region.bounds.south, region.bounds.east, region.bounds.north
301 ),
302 Self::Downloaded { provider, bytes } => {
303 let mib = bytes / 1_048_576;
304 let tenth = (bytes % 1_048_576) * 10 / 1_048_576;
305 format!("SEQUESTERED {mib}.{tenth} MIB FROM {provider}")
306 }
307 Self::Elevating { complete, total } => {
308 format!("FETCHING TOPOGRAPHY · {complete}/{total} TILES")
309 }
310 Self::Indexing => "INDEXING ROUTABLE TRAIL GRAPH".to_owned(),
311 Self::Ready(summary) if summary.reused => {
312 format!("TRAIL INDEX READY · {} EDGES · CACHED", summary.edges)
313 }
314 Self::Ready(summary) => format!("TRAIL INDEX READY · {} EDGES", summary.edges),
315 }
316 }
317}
318
319pub trait PlaceIndex {
320 fn locate_us(&self, query: &str) -> Result<Place>;
321}
322
323#[derive(Clone, Debug)]
324pub struct Nominatim {
325 endpoint: String,
326 timeout: Duration,
327}
328
329impl Default for Nominatim {
330 fn default() -> Self {
331 Self {
332 endpoint: env::var("TRAILGEN_GEOCODER_ENDPOINT")
333 .unwrap_or_else(|_| DEFAULT_NOMINATIM_ENDPOINT.to_owned()),
334 timeout: Duration::from_secs(30),
335 }
336 }
337}
338
339impl Nominatim {
340 #[must_use]
341 pub fn new(endpoint: impl Into<String>, timeout: Duration) -> Self {
342 Self {
343 endpoint: endpoint.into(),
344 timeout,
345 }
346 }
347}
348
349impl PlaceIndex for Nominatim {
350 fn locate_us(&self, query: &str) -> Result<Place> {
351 let query = query.trim();
352 ensure!(!query.is_empty(), "enter a US place or trailhead");
353 let replies = provider_client("place-search", self.timeout)
354 .context("build OpenStreetMap place-search client")?
355 .get(&self.endpoint)
356 .query(&[
357 ("q", query),
358 ("format", "jsonv2"),
359 ("countrycodes", "us"),
360 ("limit", "1"),
361 ])
362 .header("Accept-Language", "en-US,en;q=0.8")
363 .send()
364 .with_context(|| format!("search US places through {}", self.endpoint))?
365 .error_for_status()
366 .with_context(|| format!("place-search endpoint {} returned an error", self.endpoint))?
367 .json::<Vec<NominatimReply>>()
368 .context("decode OpenStreetMap place-search response")?;
369 let reply = replies
370 .into_iter()
371 .next()
372 .with_context(|| format!("no US place matched {query:?}"))?;
373 let lat = parse_coordinate(&reply.lat, "latitude")?;
374 let lon = parse_coordinate(&reply.lon, "longitude")?;
375 ensure!(
376 (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon),
377 "place search returned an invalid coordinate"
378 );
379 Ok(Place {
380 query: query.to_owned(),
381 label: reply.display_name,
382 center: Coord::new(lon, lat),
383 license: reply.licence,
384 provider: self.endpoint.clone(),
385 })
386 }
387}
388
389#[derive(Clone, Debug)]
390pub struct Overpass {
391 endpoints: Vec<String>,
392 timeout: Duration,
393}
394
395impl Default for Overpass {
396 fn default() -> Self {
397 let endpoints = env::var("TRAILGEN_OVERPASS_ENDPOINT").map_or_else(
398 |_| {
399 vec![
400 DEFAULT_OVERPASS_ENDPOINT.to_owned(),
401 FALLBACK_OVERPASS_ENDPOINT.to_owned(),
402 ]
403 },
404 |endpoint| vec![endpoint],
405 );
406 Self {
407 endpoints,
408 timeout: Duration::from_secs(90),
409 }
410 }
411}
412
413impl Overpass {
414 #[must_use]
415 pub fn new(endpoint: impl Into<String>, timeout: Duration) -> Self {
416 Self {
417 endpoints: vec![endpoint.into()],
418 timeout,
419 }
420 }
421
422 #[must_use]
423 pub fn query(&self, profile: OsmProfile, bounds: GeoBounds) -> String {
424 overpass_query(profile, bounds, self.timeout.as_secs())
425 }
426
427 pub fn fetch(&self, profile: OsmProfile, bounds: GeoBounds) -> Result<OsmPayload> {
428 let area_deg2 = (bounds.east - bounds.west) * (bounds.north - bounds.south);
429 ensure!(bounds.is_valid(), "invalid trail-data bounds");
430 ensure!(
431 area_deg2 <= MAX_REGION_DEG2,
432 "trail-data bounds span {area_deg2:.2} square degrees; limit is {MAX_REGION_DEG2:.2}"
433 );
434 let query = self.query(profile, bounds);
435 let client = provider_client("trail-source", self.timeout)
436 .context("build OpenStreetMap trail-source client")?;
437 let mut faults = Vec::new();
438 for endpoint in &self.endpoints {
439 let response = match client
440 .post(endpoint)
441 .form(&[("data", query.as_str())])
442 .send()
443 {
444 Ok(response) => response,
445 Err(err) => {
446 faults.push(format!("{endpoint}: {err}"));
447 continue;
448 }
449 };
450 let status = response.status();
451 if !status.is_success() {
452 let fault = format!("{endpoint}: HTTP {status}");
453 if status.as_u16() == 429 || status.is_server_error() {
454 faults.push(fault);
455 continue;
456 }
457 anyhow::bail!("Overpass rejected the trail query through {fault}");
458 }
459 match read_bounded(response, MAX_SOURCE_BYTES, "OpenStreetMap response") {
460 Ok(bytes) => {
461 return Ok(OsmPayload {
462 bytes,
463 query,
464 origin: endpoint.clone(),
465 });
466 }
467 Err(err) => faults.push(format!("{endpoint}: {err:#}")),
468 }
469 }
470 anyhow::bail!("all Overpass providers failed: {}", faults.join("; "))
471 }
472}
473
474pub struct Surveyor<L = Nominatim> {
475 locator: L,
476 providers: Vec<Box<dyn NetworkProvider>>,
477 fixed_providers: bool,
478}
479
480impl Default for Surveyor {
481 fn default() -> Self {
482 Self {
483 locator: Nominatim::default(),
484 providers: vec![
485 Box::new(AuthorityTrailProvider::new_york()),
486 Box::new(Overpass::default()),
487 Box::new(AuthorityTrailProvider::texas()),
488 Box::new(UsgsNationalTrails::default()),
489 ],
490 fixed_providers: false,
491 }
492 }
493}
494
495impl<L> Surveyor<L>
496where
497 L: PlaceIndex,
498{
499 #[must_use]
500 pub fn new<P: NetworkProvider + 'static>(locator: L, provider: P) -> Self {
501 Self {
502 locator,
503 providers: vec![Box::new(provider)],
504 fixed_providers: true,
505 }
506 }
507
508 #[must_use]
509 pub fn with_providers(locator: L, providers: Vec<Box<dyn NetworkProvider>>) -> Self {
510 assert!(
511 !providers.is_empty(),
512 "a surveyor needs at least one provider"
513 );
514 let distinct = providers
515 .iter()
516 .map(|provider| provider.descriptor().id)
517 .collect::<BTreeSet<_>>();
518 assert_eq!(
519 distinct.len(),
520 providers.len(),
521 "a surveyor cannot carry duplicate provider ids"
522 );
523 Self {
524 locator,
525 providers,
526 fixed_providers: true,
527 }
528 }
529
530 pub fn survey(
531 &self,
532 project: &Path,
533 query: &str,
534 radius_km: f64,
535 mut emit: impl FnMut(Event),
536 ) -> Result<Summary> {
537 validate_project(project)?;
538 let query = query.trim();
539 ensure!(!query.is_empty(), "enter a US place or trailhead");
540 validate_radius(radius_km)?;
541 emit(Event::Locating);
542 let place =
543 cached_place(project, query)?.map_or_else(|| self.locator.locate_us(query), Ok)?;
544 write_json_atomic(project.join(LOCATION_CACHE), &place)?;
545 emit(Event::Located(place.clone()));
546 self.add_region(project, bounds_around(&place, radius_km)?, emit)
547 }
548
549 pub fn add_region(
551 &self,
552 project: &Path,
553 bounds: GeoBounds,
554 mut emit: impl FnMut(Event),
555 ) -> Result<Summary> {
556 validate_project(project)?;
557 let region = SurveyRegion::new(bounds)?;
558 let mut config = project_config(project)?;
559 config.managed = true;
560 if self.fixed_providers {
561 let providers = self.provider_ids();
562 if config.providers != providers {
563 config.providers = providers;
564 }
565 }
566 if !config.regions.iter().any(|known| known.id == region.id) {
567 config.regions.push(region);
568 }
569 configure_project(project, &config)?;
570 self.reconcile(project, &config, true, &mut emit)
571 }
572
573 pub fn refresh(&self, project: &Path, mut emit: impl FnMut(Event)) -> Result<Option<Summary>> {
575 validate_project(project)?;
576 let config = project_config(project)?;
577 if config.regions.is_empty() {
578 clear_corpus(project)?;
579 reap_provider_receipts(project, &[], &self.provider_descriptors())?;
580 return Ok(None);
581 }
582 configure_project(project, &config)?;
586 self.reconcile(project, &config, true, &mut emit).map(Some)
587 }
588
589 pub fn remove_region(
591 &self,
592 project: &Path,
593 id: &str,
594 mut emit: impl FnMut(Event),
595 ) -> Result<Option<Summary>> {
596 validate_project(project)?;
597 let mut config = project_config(project)?;
598 let before = config.regions.len();
599 config.regions.retain(|region| region.id != id);
600 ensure!(
601 config.regions.len() != before,
602 "project has no survey region {id}"
603 );
604 let _name = config.region_names.remove(id);
605 configure_project(project, &config)?;
606 if config.regions.is_empty() {
607 clear_corpus(project)?;
608 reap_provider_receipts(project, &[], &self.provider_descriptors())?;
609 return Ok(None);
610 }
611 self.reconcile(project, &config, false, &mut emit).map(Some)
612 }
613
614 pub fn replace_region(
618 &self,
619 project: &Path,
620 id: &str,
621 bounds: GeoBounds,
622 mut emit: impl FnMut(Event),
623 ) -> Result<Summary> {
624 validate_project(project)?;
625 let replacement = SurveyRegion::new(bounds)?;
626 let mut config = project_config(project)?;
627 let slot = config
628 .regions
629 .iter()
630 .position(|region| region.id == id)
631 .with_context(|| format!("project has no survey region {id}"))?;
632 ensure!(
633 config
634 .regions
635 .iter()
636 .enumerate()
637 .all(|(known_slot, known)| known_slot == slot || known.id != replacement.id),
638 "that map area duplicates another downloaded area"
639 );
640 if replacement.id == id {
641 return self.reconcile(project, &config, true, &mut emit);
642 }
643 config.regions[slot] = replacement;
644 let replacement_id = config.regions[slot].id.clone();
645 if let Some(name) = config.region_names.remove(id) {
646 let _old = config.region_names.insert(replacement_id, name);
647 }
648 configure_project(project, &config)?;
649 self.reconcile(project, &config, true, &mut emit)
650 }
651
652 fn reconcile(
653 &self,
654 project: &Path,
655 config: &TrailDataConfig,
656 fetch_missing: bool,
657 emit: &mut impl FnMut(Event),
658 ) -> Result<Summary> {
659 ensure!(
660 !config.regions.is_empty(),
661 "live area has no survey regions"
662 );
663 let providers = self.active_providers(config)?;
664 let descriptors = providers
665 .iter()
666 .map(|provider| provider.descriptor())
667 .collect::<Vec<_>>();
668 if let Some(mut summary) =
669 reusable_index(project, config, Some(&descriptors), !self.fixed_providers)?
670 {
671 summary.reused = true;
672 emit(Event::Ready(summary.clone()));
673 return Ok(summary);
674 }
675
676 let mut sources = Vec::with_capacity(config.regions.len() * providers.len());
677 for provider in &providers {
678 let descriptor = provider.descriptor();
679 for region in &config.regions {
680 let raw_relative = PathBuf::from("sources")
681 .join(descriptor.id.as_str())
682 .join(format!("{}.{}", region.id, descriptor.extension));
683 let raw_path = project.join(&raw_relative);
684 let request_path = raw_path.with_extension(descriptor.request_extension);
685 let artifact_path = raw_path.with_extension("json");
686 let cached = cached_provider(
687 &raw_path,
688 &request_path,
689 &artifact_path,
690 region,
691 &descriptor,
692 )?;
693 let (bytes, origin) = if let Some(cached) = cached {
694 (cached.bytes, cached.origin)
695 } else {
696 ensure!(
697 fetch_missing,
698 "{} region {} has no intact source receipt",
699 descriptor.label,
700 region.id
701 );
702 let covered = provider.covers(region.bounds);
703 if covered {
704 emit(Event::Ranging {
705 provider: descriptor.id.clone(),
706 region: region.clone(),
707 });
708 }
709 let payload = provider.acquire(region.bounds)?;
710 let artifact = ProviderArtifact {
711 schema: RAW_SCHEMA,
712 provider: descriptor.id.clone(),
713 adapter_revision: descriptor.adapter_revision,
714 region: region.clone(),
715 origin: payload.origin.clone(),
716 request: payload.request.clone(),
717 raw: fingerprint(&payload.bytes),
718 };
719 write_atomic(&request_path, payload.request.as_bytes())?;
720 write_json_atomic(&artifact_path, &artifact)?;
721 write_atomic(&raw_path, &payload.bytes)?;
723 if covered {
724 emit(Event::Downloaded {
725 provider: descriptor.id.clone(),
726 bytes: payload.bytes.len() as u64,
727 });
728 }
729 (payload.bytes, payload.origin)
730 };
731 sources.push(ProviderSource {
732 descriptor: descriptor.clone(),
733 region: region.clone(),
734 raw_relative,
735 fingerprint: fingerprint(&bytes),
736 bytes,
737 origin,
738 });
739 }
740 }
741 let terrain = if self.fixed_providers {
742 Vec::new()
743 } else {
744 terrain::acquire(project, &config.regions, fetch_missing, emit)?
745 };
746 emit(Event::Indexing);
747 let summary = index_corpus(project, config, &sources, &providers, &terrain)?;
748 reap_provider_receipts(project, &sources, &self.provider_descriptors())?;
749 emit(Event::Ready(summary.clone()));
750 Ok(summary)
751 }
752
753 fn provider_ids(&self) -> Vec<ProviderId> {
754 let mut ids = self
755 .providers
756 .iter()
757 .map(|provider| provider.descriptor().id)
758 .collect::<Vec<_>>();
759 ids.sort();
760 ids.dedup();
761 ids
762 }
763
764 fn provider_descriptors(&self) -> Vec<ProviderDescriptor> {
765 self.providers
766 .iter()
767 .map(|provider| provider.descriptor())
768 .collect()
769 }
770
771 fn active_providers(&self, config: &TrailDataConfig) -> Result<Vec<&dyn NetworkProvider>> {
772 let mut providers = Vec::with_capacity(config.providers.len());
773 for id in &config.providers {
774 let provider = self
775 .providers
776 .iter()
777 .find(|provider| provider.descriptor().id == *id)
778 .with_context(|| format!("project requests unavailable trail provider {id}"))?;
779 providers.push(provider.as_ref());
780 }
781 ensure!(!providers.is_empty(), "project has no trail providers");
782 Ok(providers)
783 }
784}
785
786pub struct OsmPayload {
787 pub bytes: Vec<u8>,
788 pub query: String,
789 pub origin: String,
790}
791
792impl NetworkProvider for Overpass {
793 fn descriptor(&self) -> ProviderDescriptor {
794 ProviderDescriptor {
795 id: ProviderId::new("osm").expect("static provider id is valid"),
796 label: "OpenStreetMap",
797 adapter_revision: 7,
798 precedence: 10,
799 extension: "osm",
800 request_extension: "overpassql",
801 }
802 }
803
804 fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
805 let payload = self.fetch(AUTOMATIC_OSM_PROFILE, bounds)?;
806 Ok(ProviderPayload {
807 bytes: payload.bytes,
808 request: payload.query,
809 origin: payload.origin,
810 })
811 }
812
813 fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
814 let merged = merge_osm(shards)?;
815 Ok(NormalizedNetwork {
816 drafts: classify_osm_realms(osm::network_from_str(&merged)?),
817 context: osm::context_overlays_from_str(&merged)?,
818 })
819 }
820}
821
822type OsmJunction = JunctionKey;
823
824#[derive(Clone, Debug)]
825struct OsmConnector {
826 draft: usize,
827 a: OsmJunction,
828 b: OsmJunction,
829 length_m: f64,
830}
831
832#[derive(Clone, Debug, PartialEq)]
833struct OsmWalk {
834 junction: OsmJunction,
835 owner: OsmJunction,
836 distance_m: f64,
837}
838
839#[derive(Clone, Debug)]
840struct OsmReach {
841 owner: OsmJunction,
842 distance_m: f64,
843 predecessor: Option<(OsmJunction, usize)>,
844}
845
846#[derive(Clone, Debug)]
847struct OsmBridge {
848 distance_m: f64,
849 edge: usize,
850 a: OsmJunction,
851 b: OsmJunction,
852}
853
854impl Eq for OsmWalk {}
855
856impl Ord for OsmWalk {
857 fn cmp(&self, other: &Self) -> Ordering {
858 other
859 .distance_m
860 .total_cmp(&self.distance_m)
861 .then_with(|| other.owner.cmp(&self.owner))
862 .then_with(|| self.junction.cmp(&other.junction))
863 }
864}
865
866impl PartialOrd for OsmWalk {
867 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
868 Some(self.cmp(other))
869 }
870}
871
872fn classify_osm_realms(mut drafts: Vec<SegmentDraft>) -> Vec<SegmentDraft> {
876 let mut finder = drafts.iter().map(osm_trail_anchor).collect::<Vec<_>>();
877 let mut anchor_junctions = BTreeSet::new();
878 let mut connectors = Vec::new();
879 for (index, draft) in drafts.iter().enumerate() {
880 let Some([a, b]) = draft.junction_keys.clone() else {
881 continue;
882 };
883 if finder[index] {
884 let _ = anchor_junctions.insert(a.clone());
885 let _ = anchor_junctions.insert(b.clone());
886 } else if osm_connector(draft) {
887 connectors.push(OsmConnector {
888 draft: index,
889 a,
890 b,
891 length_m: draft.geometry.length_m(),
892 });
893 }
894 }
895
896 let mut adjacency = BTreeMap::<OsmJunction, Vec<usize>>::new();
897 for (index, connector) in connectors.iter().enumerate() {
898 adjacency
899 .entry(connector.a.clone())
900 .or_default()
901 .push(index);
902 adjacency
903 .entry(connector.b.clone())
904 .or_default()
905 .push(index);
906 }
907 let terminals = adjacency
908 .keys()
909 .filter(|junction| anchor_junctions.contains(junction))
910 .cloned()
911 .collect::<BTreeSet<_>>();
912 retain_nearest_connectors(&terminals, &adjacency, &connectors, &mut finder);
913 for (draft, admitted) in drafts.iter_mut().zip(finder) {
914 if admitted && draft.realm == WayRealm::Urban {
915 draft.realm = WayRealm::Connector;
916 }
917 }
918 drafts
919}
920
921fn retain_nearest_connectors(
922 terminals: &BTreeSet<OsmJunction>,
923 adjacency: &BTreeMap<OsmJunction, Vec<usize>>,
924 connectors: &[OsmConnector],
925 finder: &mut [bool],
926) {
927 let mut reach = terminals
928 .iter()
929 .cloned()
930 .map(|terminal| {
931 (
932 terminal.clone(),
933 OsmReach {
934 owner: terminal,
935 distance_m: 0.0,
936 predecessor: None,
937 },
938 )
939 })
940 .collect::<BTreeMap<_, _>>();
941 let mut frontier = terminals
942 .iter()
943 .cloned()
944 .map(|terminal| OsmWalk {
945 junction: terminal.clone(),
946 owner: terminal,
947 distance_m: 0.0,
948 })
949 .collect::<BinaryHeap<_>>();
950 while let Some(walk) = frontier.pop() {
951 if walk.distance_m > MAX_OSM_CONNECTOR_M
952 || reach
953 .get(&walk.junction)
954 .is_none_or(|known| known.owner != walk.owner || walk.distance_m > known.distance_m)
955 {
956 continue;
957 }
958 for edge in adjacency.get(&walk.junction).into_iter().flatten() {
959 let connector = &connectors[*edge];
960 let next = if connector.a == walk.junction {
961 connector.b.clone()
962 } else {
963 connector.a.clone()
964 };
965 let candidate = walk.distance_m + connector.length_m;
966 let improves = reach.get(&next).is_none_or(|known| {
967 candidate.total_cmp(&known.distance_m).is_lt()
968 || candidate.total_cmp(&known.distance_m).is_eq() && walk.owner < known.owner
969 });
970 if candidate <= MAX_OSM_CONNECTOR_M && improves {
971 let _ = reach.insert(
972 next.clone(),
973 OsmReach {
974 owner: walk.owner.clone(),
975 distance_m: candidate,
976 predecessor: Some((walk.junction.clone(), *edge)),
977 },
978 );
979 frontier.push(OsmWalk {
980 junction: next,
981 owner: walk.owner.clone(),
982 distance_m: candidate,
983 });
984 }
985 }
986 }
987
988 let mut nearest = BTreeMap::<OsmJunction, OsmBridge>::new();
989 for (edge, connector) in connectors.iter().enumerate() {
990 let (Some(a), Some(b)) = (reach.get(&connector.a), reach.get(&connector.b)) else {
991 continue;
992 };
993 if a.owner == b.owner {
994 continue;
995 }
996 let distance_m = a.distance_m + connector.length_m + b.distance_m;
997 if distance_m > MAX_OSM_CONNECTOR_M {
998 continue;
999 }
1000 let bridge = OsmBridge {
1001 distance_m,
1002 edge,
1003 a: connector.a.clone(),
1004 b: connector.b.clone(),
1005 };
1006 for owner in [&a.owner, &b.owner] {
1007 let replace = nearest
1008 .get(owner)
1009 .is_none_or(|known| bridge_order(&bridge, known).is_lt());
1010 if replace {
1011 let _ = nearest.insert(owner.clone(), bridge.clone());
1012 }
1013 }
1014 }
1015 for bridge in nearest.into_values() {
1016 finder[connectors[bridge.edge].draft] = true;
1017 retain_reach(&bridge.a, &reach, connectors, finder);
1018 retain_reach(&bridge.b, &reach, connectors, finder);
1019 }
1020}
1021
1022fn bridge_order(left: &OsmBridge, right: &OsmBridge) -> Ordering {
1023 left.distance_m
1024 .total_cmp(&right.distance_m)
1025 .then_with(|| left.edge.cmp(&right.edge))
1026 .then_with(|| left.a.cmp(&right.a))
1027 .then_with(|| left.b.cmp(&right.b))
1028}
1029
1030fn retain_reach(
1031 start: &OsmJunction,
1032 reach: &BTreeMap<OsmJunction, OsmReach>,
1033 connectors: &[OsmConnector],
1034 finder: &mut [bool],
1035) {
1036 let mut junction = start.clone();
1037 while let Some((prior, edge)) = reach
1038 .get(&junction)
1039 .and_then(|label| label.predecessor.clone())
1040 {
1041 finder[connectors[edge].draft] = true;
1042 junction = prior;
1043 }
1044}
1045
1046fn osm_trail_anchor(draft: &SegmentDraft) -> bool {
1047 draft.realm == WayRealm::Recreational
1048}
1049
1050fn osm_connector(draft: &SegmentDraft) -> bool {
1051 draft.realm == WayRealm::Urban
1052}
1053
1054#[derive(Clone, Debug, Eq, PartialEq)]
1055pub struct OsmInventory {
1056 pub trail_segments: usize,
1057 pub road_features: usize,
1058 pub waterway_features: usize,
1059}
1060
1061pub fn inspect_osm(profile: OsmProfile, raw: &str) -> Result<OsmInventory> {
1062 let trails = if matches!(profile, OsmProfile::All | OsmProfile::Trails) {
1063 osm::network_from_str(raw)?.len()
1064 } else {
1065 0
1066 };
1067 let (roads, waterways) = if matches!(
1068 profile,
1069 OsmProfile::All | OsmProfile::Roads | OsmProfile::Hydrology
1070 ) {
1071 osm::context_overlays_from_str(raw)?.into_iter().fold(
1072 (0, 0),
1073 |(roads, waterways), overlay| match overlay.kind {
1074 trailgen_core::CrossingKind::Road => (roads + 1, waterways),
1075 trailgen_core::CrossingKind::Water => (roads, waterways + 1),
1076 },
1077 )
1078 } else {
1079 (0, 0)
1080 };
1081 let inventory = OsmInventory {
1082 trail_segments: trails,
1083 road_features: roads,
1084 waterway_features: waterways,
1085 };
1086 let selected = match profile {
1087 OsmProfile::All => trails + roads + waterways,
1088 OsmProfile::Trails => trails,
1089 OsmProfile::Roads => roads,
1090 OsmProfile::Hydrology => waterways,
1091 };
1092 ensure!(
1093 selected > 0,
1094 "OpenStreetMap response contained no normalizable {} ways",
1095 profile.label()
1096 );
1097 Ok(inventory)
1098}
1099
1100#[must_use]
1101pub fn overpass_query(profile: OsmProfile, area: GeoBounds, timeout_s: u64) -> String {
1102 let bbox = format!(
1103 "({},{},{},{})",
1104 area.south, area.west, area.north, area.east
1105 );
1106 if matches!(profile, OsmProfile::All | OsmProfile::Trails) {
1107 return trail_overpass_query(&bbox, timeout_s, profile == OsmProfile::All);
1108 }
1109 let selectors = match profile {
1110 OsmProfile::Roads => OSM_ROAD_SELECTORS.to_vec(),
1111 OsmProfile::Hydrology => OSM_HYDROLOGY_SELECTORS.to_vec(),
1112 OsmProfile::All | OsmProfile::Trails => unreachable!("trail profiles handled above"),
1113 };
1114 let mut query = format!("[out:xml][timeout:{timeout_s}];\n(\n");
1115 for selector in selectors {
1116 query.push_str(" ");
1117 query.push_str(selector);
1118 query.push_str(&bbox);
1119 query.push_str(";\n");
1120 }
1121 query.push_str(");\n(._;>;);\nout body;\n");
1122 query
1123}
1124
1125fn trail_overpass_query(bbox: &str, timeout_s: u64, context: bool) -> String {
1126 let mut query = format!("[out:xml][timeout:{timeout_s}];\n(\n");
1127 for selector in OSM_TRAIL_SELECTORS {
1128 writeln!(query, " {selector}{bbox};").expect("write to string");
1129 }
1130 query.push_str(
1131 ")->.trailways;\n\
1132 rel(bw.trailways)[\"type\"=\"route\"][\"route\"~\"^(hiking|foot|walking)$\"]->.routes;\n\
1133 rel(bw.trailways)[\"type\"=\"restriction\"][\"restriction:foot\"]->.restrictions;\n",
1134 );
1135 if context {
1136 query.push_str("node(w.trailways)->.trailnodes;\n(\n");
1137 writeln!(query, " {}{bbox};", OSM_ROAD_SELECTORS[0]).expect("write to string");
1138 let hydrology = OSM_HYDROLOGY_SELECTORS[0]
1139 .strip_prefix("way")
1140 .expect("way selector");
1141 writeln!(query, " way(bn.trailnodes){hydrology};").expect("write to string");
1142 query.push_str(
1143 ")->.context;\n\
1144 (.trailways; .routes; .restrictions; .context; .trailways >; .context >;);\n",
1145 );
1146 } else {
1147 query.push_str("(.trailways; .routes; .restrictions; .trailways >;);\n");
1148 }
1149 query.push_str("out body;\n");
1150 query
1151}
1152
1153#[must_use]
1154pub const fn overpass_selector_count(profile: OsmProfile) -> usize {
1155 match profile {
1156 OsmProfile::All => {
1157 OSM_TRAIL_SELECTORS.len() + OSM_ROAD_SELECTORS.len() + OSM_HYDROLOGY_SELECTORS.len() + 2
1158 }
1159 OsmProfile::Trails => OSM_TRAIL_SELECTORS.len() + 2,
1160 OsmProfile::Roads => OSM_ROAD_SELECTORS.len(),
1161 OsmProfile::Hydrology => OSM_HYDROLOGY_SELECTORS.len(),
1162 }
1163}
1164
1165#[derive(Deserialize)]
1166struct NominatimReply {
1167 licence: String,
1168 lat: String,
1169 lon: String,
1170 display_name: String,
1171}
1172
1173#[derive(Clone, Debug, Deserialize, Serialize)]
1174struct TrailIndex {
1175 schema: u8,
1176 summary: Summary,
1177 sources: Vec<ProviderReceipt>,
1178 #[serde(default)]
1179 elevation: Vec<terrain::TerrainReceipt>,
1180 graph: SourceFingerprint,
1181}
1182
1183#[derive(Clone, Debug, Deserialize, Serialize)]
1184struct ProviderReceipt {
1185 provider: ProviderId,
1186 adapter_revision: u16,
1187 region: SurveyRegion,
1188 raw_path: PathBuf,
1189 raw: SourceFingerprint,
1190}
1191
1192#[derive(Clone, Debug, Deserialize, Serialize)]
1193struct ProviderArtifact {
1194 schema: u8,
1195 provider: ProviderId,
1196 adapter_revision: u16,
1197 region: SurveyRegion,
1198 origin: String,
1199 request: String,
1200 raw: SourceFingerprint,
1201}
1202
1203struct CachedProvider {
1204 bytes: Vec<u8>,
1205 origin: String,
1206}
1207
1208struct ProviderSource {
1209 descriptor: ProviderDescriptor,
1210 region: SurveyRegion,
1211 raw_relative: PathBuf,
1212 fingerprint: SourceFingerprint,
1213 bytes: Vec<u8>,
1214 origin: String,
1215}
1216
1217#[derive(Clone, Copy, Deserialize)]
1218#[serde(default)]
1219struct GraphLaw {
1220 snap_tolerance_m: f64,
1221 conflation: trailgen_core::ConflationPolicy,
1222 enrichment: EnrichmentConfig,
1223}
1224
1225impl Default for GraphLaw {
1226 fn default() -> Self {
1227 Self {
1228 snap_tolerance_m: DEFAULT_SNAP_TOLERANCE_M,
1229 conflation: trailgen_core::ConflationPolicy::default(),
1230 enrichment: EnrichmentConfig::default(),
1231 }
1232 }
1233}
1234
1235fn index_corpus(
1236 project: &Path,
1237 config: &TrailDataConfig,
1238 sources: &[ProviderSource],
1239 providers: &[&dyn NetworkProvider],
1240 terrain: &[terrain::TerrainSource],
1241) -> Result<Summary> {
1242 let corpus_bytes = sources
1243 .iter()
1244 .map(|source| source.bytes.len() as u64)
1245 .sum::<u64>();
1246 ensure!(
1247 corpus_bytes <= MAX_SOURCE_BYTES * providers.len().max(1) as u64 * 4,
1248 "live trail corpus exceeds {} MiB",
1249 MAX_SOURCE_BYTES * providers.len().max(1) as u64 * 4 / 1_048_576
1250 );
1251 let mut strata = Vec::with_capacity(providers.len());
1252 let mut overlays = Vec::new();
1253 for provider in providers {
1254 let descriptor = provider.descriptor();
1255 let shards = sources
1256 .iter()
1257 .filter(|source| source.descriptor.id == descriptor.id)
1258 .map(|source| RawShard {
1259 region: &source.region,
1260 bytes: &source.bytes,
1261 })
1262 .collect::<Vec<_>>();
1263 let normalized = provider.normalize(&shards)?;
1264 strata.push(trailgen_core::NetworkStratum {
1265 precedence: descriptor.precedence,
1266 drafts: clip_drafts(normalized.drafts, &config.regions),
1267 });
1268 overlays.extend(clip_overlays(normalized.context, &config.regions));
1269 }
1270 let law = read_graph_law(project)?;
1271 let conflated = trailgen_core::conflate(strata, law.conflation);
1272 let drafts = conflated.drafts;
1273 ensure!(
1274 !drafts.is_empty(),
1275 "the live area contains no routable trails"
1276 );
1277 let inventory = Inventory {
1278 trail_segments: drafts.len(),
1279 road_features: overlays
1280 .iter()
1281 .filter(|overlay| overlay.kind == CrossingKind::Road)
1282 .count(),
1283 waterway_features: overlays
1284 .iter()
1285 .filter(|overlay| overlay.kind == CrossingKind::Water)
1286 .count(),
1287 };
1288 let graph = forge_graph(&drafts, &overlays, terrain, law)?;
1289 let graph_cache = encode_graph(&graph)?;
1290 let graph_fingerprint = fingerprint(&graph_cache);
1291 clear_graph_auxiliaries(project)?;
1292 write_json_atomic(project.join(CONFLATION_REPORT), &conflated.report)?;
1293 let bounds = live_bounds(&config.regions).context("live area has no bounds")?;
1294 store_area(project, Some(bounds))?;
1295 write_source_manifest(project, sources, &inventory, bounds)?;
1296 let summary = Summary {
1297 regions: config.regions.clone(),
1298 providers: config.providers.clone(),
1299 inventory,
1300 vertices: graph.vertices.len(),
1301 edges: graph.edges.len(),
1302 elevation_tiles: terrain.len(),
1303 raw_paths: sources
1304 .iter()
1305 .map(|source| source.raw_relative.clone())
1306 .collect(),
1307 conflation: trailgen_core::ConflationStats::from(&conflated.report),
1308 reused: false,
1309 };
1310 let receipts = sources
1311 .iter()
1312 .map(|source| ProviderReceipt {
1313 provider: source.descriptor.id.clone(),
1314 adapter_revision: source.descriptor.adapter_revision,
1315 region: source.region.clone(),
1316 raw_path: source.raw_relative.clone(),
1317 raw: source.fingerprint.clone(),
1318 })
1319 .collect();
1320 write_json_atomic(
1321 project.join(TRAIL_INDEX),
1322 &TrailIndex {
1323 schema: INDEX_SCHEMA,
1324 summary: summary.clone(),
1325 sources: receipts,
1326 elevation: terrain
1327 .iter()
1328 .map(|source| source.receipt.clone())
1329 .collect(),
1330 graph: graph_fingerprint,
1331 },
1332 )?;
1333 write_atomic(&project.join(GRAPH_CACHE), &graph_cache)?;
1336 Ok(summary)
1337}
1338
1339fn forge_graph(
1340 drafts: &[SegmentDraft],
1341 overlays: &[ContextOverlay],
1342 terrain: &[terrain::TerrainSource],
1343 law: GraphLaw,
1344) -> Result<WalkGraph> {
1345 let mut graph = GraphBuilder {
1346 snap_tolerance_m: law.snap_tolerance_m,
1347 enrichment: law.enrichment,
1348 }
1349 .build(drafts)
1350 .context("index live-area trail topology")?;
1351 apply_context_overlays(&mut graph, overlays);
1352 if let Some(atlas) = terrain::TerrainAtlas::decode(terrain)? {
1353 trailgen_core::enrich_graph(&mut graph, &atlas, law.enrichment)
1354 .context("sample live-area topography")?;
1355 }
1356 Ok(graph)
1357}
1358
1359fn merge_osm(sources: &[RawShard<'_>]) -> Result<String> {
1360 struct Object {
1361 version: u64,
1362 xml: String,
1363 }
1364
1365 let mut objects = BTreeMap::<(u8, String), Object>::new();
1366 for source in sources {
1367 let raw =
1368 std::str::from_utf8(source.bytes).context("OpenStreetMap response is not UTF-8 XML")?;
1369 let document = roxmltree::Document::parse(raw).context("parse region OSM XML")?;
1370 let root = document.root_element();
1371 ensure!(root.has_tag_name("osm"), "region source has no OSM root");
1372 for node in root.children().filter(roxmltree::Node::is_element) {
1373 let rank = match node.tag_name().name() {
1374 "node" => 0,
1375 "way" => 1,
1376 "relation" => 2,
1377 _ => continue,
1378 };
1379 let Some(id) = node.attribute("id") else {
1380 continue;
1381 };
1382 let version = node
1383 .attribute("version")
1384 .and_then(|value| value.parse().ok())
1385 .unwrap_or(0);
1386 let xml = raw[node.range()].to_owned();
1387 let key = (rank, id.to_owned());
1388 match objects.get_mut(&key) {
1389 Some(known)
1390 if version > known.version || (version == known.version && xml > known.xml) =>
1391 {
1392 *known = Object { version, xml };
1393 }
1394 Some(_) => {}
1395 None => {
1396 objects.insert(key, Object { version, xml });
1397 }
1398 }
1399 }
1400 }
1401 let mut merged = String::from(
1402 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<osm version=\"0.6\" generator=\"trailgen-corpus\">\n",
1403 );
1404 for object in objects.into_values() {
1405 merged.push_str(&object.xml);
1406 merged.push('\n');
1407 }
1408 merged.push_str("</osm>\n");
1409 Ok(merged)
1410}
1411
1412fn clip_drafts(drafts: Vec<SegmentDraft>, regions: &[SurveyRegion]) -> Vec<SegmentDraft> {
1413 drafts
1414 .into_iter()
1415 .flat_map(|draft| {
1416 clip_line(&draft.geometry, regions)
1417 .into_iter()
1418 .map(move |geometry| draft.fragment(geometry))
1419 })
1420 .collect()
1421}
1422
1423fn clip_overlays(overlays: Vec<ContextOverlay>, regions: &[SurveyRegion]) -> Vec<ContextOverlay> {
1424 overlays
1425 .into_iter()
1426 .flat_map(|overlay| {
1427 clip_line(&overlay.geometry, regions)
1428 .into_iter()
1429 .map(move |geometry| {
1430 let mut clipped = overlay.clone();
1431 clipped.geometry = geometry;
1432 clipped
1433 })
1434 })
1435 .collect()
1436}
1437
1438fn clip_line(line: &LineString, regions: &[SurveyRegion]) -> Vec<LineString> {
1439 let mut result = Vec::new();
1440 let mut points = Vec::new();
1441 for segment in line.points.windows(2) {
1442 let [a, b] = [segment[0], segment[1]];
1443 let mut cuts = vec![0.0, 1.0];
1444 for region in regions {
1445 let dx = b.lon - a.lon;
1446 if dx.abs() > f64::EPSILON {
1447 cuts.extend(
1448 [region.bounds.west, region.bounds.east]
1449 .map(|lon| (lon - a.lon) / dx)
1450 .into_iter()
1451 .filter(|t| (0.0..=1.0).contains(t)),
1452 );
1453 }
1454 let dy = b.lat - a.lat;
1455 if dy.abs() > f64::EPSILON {
1456 cuts.extend(
1457 [region.bounds.south, region.bounds.north]
1458 .map(|lat| (lat - a.lat) / dy)
1459 .into_iter()
1460 .filter(|t| (0.0..=1.0).contains(t)),
1461 );
1462 }
1463 }
1464 cuts.sort_by(f64::total_cmp);
1465 cuts.dedup_by(|left, right| (*left - *right).abs() <= 1.0e-12);
1466 for interval in cuts.windows(2) {
1467 let [from, to] = [interval[0], interval[1]];
1468 if to - from <= 1.0e-12 {
1469 continue;
1470 }
1471 let midpoint = a.lerp(b, (from + to) * 0.5);
1472 if regions
1473 .iter()
1474 .any(|region| contains(region.bounds, midpoint))
1475 {
1476 append_clipped_segment(&mut result, &mut points, a.lerp(b, from), a.lerp(b, to));
1477 } else {
1478 seal_line(&mut result, &mut points);
1479 }
1480 }
1481 }
1482 seal_line(&mut result, &mut points);
1483 result
1484}
1485
1486fn append_clipped_segment(
1487 result: &mut Vec<LineString>,
1488 points: &mut Vec<Coord>,
1489 start: Coord,
1490 end: Coord,
1491) {
1492 let joins = points
1493 .last()
1494 .is_some_and(|last| same_location(*last, start));
1495 if !joins {
1496 seal_line(result, points);
1497 points.push(start);
1498 }
1499 if points.last().is_none_or(|last| !same_location(*last, end)) {
1500 points.push(end);
1501 }
1502}
1503
1504fn seal_line(result: &mut Vec<LineString>, points: &mut Vec<Coord>) {
1505 if points.len() >= 2 {
1506 result.push(LineString::unchecked(std::mem::take(points)));
1507 } else {
1508 points.clear();
1509 }
1510}
1511
1512fn contains(bounds: GeoBounds, coord: Coord) -> bool {
1513 bounds.west <= coord.lon
1514 && coord.lon <= bounds.east
1515 && bounds.south <= coord.lat
1516 && coord.lat <= bounds.north
1517}
1518
1519const fn same_location(left: Coord, right: Coord) -> bool {
1520 left.lon.to_bits() == right.lon.to_bits() && left.lat.to_bits() == right.lat.to_bits()
1521}
1522
1523fn cached_provider(
1524 raw_path: &Path,
1525 request_path: &Path,
1526 artifact_path: &Path,
1527 region: &SurveyRegion,
1528 descriptor: &ProviderDescriptor,
1529) -> Result<Option<CachedProvider>> {
1530 let bytes = match fs::read(raw_path) {
1531 Ok(bytes) => bytes,
1532 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1533 Err(err) => {
1534 return Err(err).with_context(|| format!("read trail source {}", raw_path.display()));
1535 }
1536 };
1537 let raw = match fs::read_to_string(artifact_path) {
1538 Ok(raw) => raw,
1539 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1540 Err(err) => {
1541 return Err(err)
1542 .with_context(|| format!("read trail-source index {}", artifact_path.display()));
1543 }
1544 };
1545 let Ok(artifact) = serde_json::from_str::<ProviderArtifact>(&raw) else {
1546 return Ok(None);
1547 };
1548 let request = match fs::read_to_string(request_path) {
1549 Ok(request) => request,
1550 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1551 Err(err) => {
1552 return Err(err)
1553 .with_context(|| format!("read trail-source request {}", request_path.display()));
1554 }
1555 };
1556 if artifact.schema != RAW_SCHEMA
1557 || artifact.provider != descriptor.id
1558 || artifact.adapter_revision != descriptor.adapter_revision
1559 || &artifact.region != region
1560 || artifact.request != request
1561 || artifact.raw != fingerprint(&bytes)
1562 {
1563 return Ok(None);
1564 }
1565 Ok(Some(CachedProvider {
1566 bytes,
1567 origin: artifact.origin,
1568 }))
1569}
1570
1571fn cached_place(project: &Path, query: &str) -> Result<Option<Place>> {
1572 let path = project.join(LOCATION_CACHE);
1573 match fs::read_to_string(&path) {
1574 Ok(raw) => {
1575 let Ok(place) = serde_json::from_str::<Place>(&raw) else {
1576 return Ok(None);
1577 };
1578 Ok(place
1579 .query
1580 .trim()
1581 .eq_ignore_ascii_case(query.trim())
1582 .then_some(place))
1583 }
1584 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1585 Err(err) => Err(err).with_context(|| format!("read location cache {}", path.display())),
1586 }
1587}
1588
1589fn reusable_index(
1590 project: &Path,
1591 config: &TrailDataConfig,
1592 descriptors: Option<&[ProviderDescriptor]>,
1593 terrain_expected: bool,
1594) -> Result<Option<Summary>> {
1595 let index_path = project.join(TRAIL_INDEX);
1596 let raw = match fs::read_to_string(&index_path) {
1597 Ok(raw) => raw,
1598 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1599 Err(err) => {
1600 return Err(err).with_context(|| format!("read trail index {}", index_path.display()));
1601 }
1602 };
1603 let Ok(index) = serde_json::from_str::<TrailIndex>(&raw) else {
1604 return Ok(None);
1605 };
1606 if !index_matches_config(&index, config)
1607 || !project.join(GRAPH_CACHE).is_file()
1608 || !project.join(CONFLATION_REPORT).is_file()
1609 {
1610 return Ok(None);
1611 }
1612 let mut receipts = BTreeSet::new();
1613 for receipt in &index.sources {
1614 let current = descriptors.and_then(|descriptors| {
1615 descriptors
1616 .iter()
1617 .find(|descriptor| descriptor.id == receipt.provider)
1618 });
1619 if !config.regions.contains(&receipt.region)
1620 || !config.providers.contains(&receipt.provider)
1621 || receipt.adapter_revision == 0
1622 || current.is_some_and(|descriptor| {
1623 receipt.adapter_revision != descriptor.adapter_revision
1624 || receipt.raw_path
1625 != PathBuf::from("sources")
1626 .join(descriptor.id.as_str())
1627 .join(format!("{}.{}", receipt.region.id, descriptor.extension))
1628 })
1629 || !receipts.insert((receipt.provider.clone(), receipt.region.id.clone()))
1630 {
1631 return Ok(None);
1632 }
1633 let bytes = match fs::read(project.join(&receipt.raw_path)) {
1634 Ok(bytes) => bytes,
1635 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1636 Err(err) => return Err(err).context("read region source receipt"),
1637 };
1638 if fingerprint(&bytes) != receipt.raw {
1639 return Ok(None);
1640 }
1641 }
1642 if receipts.len() != config.regions.len() * config.providers.len() {
1643 return Ok(None);
1644 }
1645 if terrain_expected {
1646 let desired = terrain::desired_tiles(&config.regions);
1647 if index.elevation.len() != desired.len()
1648 || index
1649 .elevation
1650 .iter()
1651 .map(|receipt| receipt.tile)
1652 .collect::<Vec<_>>()
1653 != desired
1654 {
1655 return Ok(None);
1656 }
1657 for receipt in &index.elevation {
1658 let bytes = match fs::read(project.join(&receipt.raw_path)) {
1659 Ok(bytes) => bytes,
1660 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1661 Err(err) => return Err(err).context("read terrain receipt"),
1662 };
1663 if fingerprint(&bytes) != receipt.raw {
1664 return Ok(None);
1665 }
1666 }
1667 } else if !index.elevation.is_empty() {
1668 return Ok(None);
1669 }
1670 let graph_bytes = fs::read(project.join(GRAPH_CACHE)).context("read cached trail graph")?;
1671 if fingerprint(&graph_bytes) != index.graph {
1672 return Ok(None);
1673 }
1674 let Ok(graph) = decode_graph(&graph_bytes) else {
1675 return Ok(None);
1676 };
1677 if graph.vertices.len() != index.summary.vertices || graph.edges.len() != index.summary.edges {
1678 return Ok(None);
1679 }
1680 Ok(Some(index.summary))
1681}
1682
1683fn index_matches_config(index: &TrailIndex, config: &TrailDataConfig) -> bool {
1684 let mut receipts = BTreeSet::new();
1685 let lawful_receipts = index.sources.iter().all(|receipt| {
1686 config.regions.contains(&receipt.region)
1687 && config.providers.contains(&receipt.provider)
1688 && receipt.adapter_revision != 0
1689 && receipts.insert((receipt.provider.clone(), receipt.region.id.clone()))
1690 });
1691 let raw_paths = index
1692 .sources
1693 .iter()
1694 .map(|receipt| receipt.raw_path.as_path())
1695 .collect::<BTreeSet<_>>();
1696 index.schema == INDEX_SCHEMA
1697 && index.summary.regions == config.regions
1698 && index.summary.providers == config.providers
1699 && index.sources.len() == config.regions.len() * config.providers.len()
1700 && lawful_receipts
1701 && receipts.len() == index.sources.len()
1702 && raw_paths.len() == index.sources.len()
1703 && raw_paths
1704 == index
1705 .summary
1706 .raw_paths
1707 .iter()
1708 .map(PathBuf::as_path)
1709 .collect()
1710}
1711
1712fn bounds_around(place: &Place, radius_km: f64) -> Result<GeoBounds> {
1713 validate_radius(radius_km)?;
1714 let lat_radius = radius_km / 111.32;
1715 let longitude_scale = place.center.lat.to_radians().cos().abs().max(0.05);
1716 let lon_radius = radius_km / (111.32 * longitude_scale);
1717 let bounds = GeoBounds::new(
1718 (place.center.lon - lon_radius).max(-180.0),
1719 (place.center.lat - lat_radius).max(-90.0),
1720 (place.center.lon + lon_radius).min(180.0),
1721 (place.center.lat + lat_radius).min(90.0),
1722 );
1723 validate_region(bounds)?;
1724 Ok(bounds)
1725}
1726
1727fn validate_radius(radius_km: f64) -> Result<()> {
1728 ensure!(
1729 radius_km.is_finite() && (MIN_RADIUS_KM..=MAX_RADIUS_KM).contains(&radius_km),
1730 "trail survey radius must be within {MIN_RADIUS_KM}–{MAX_RADIUS_KM} km"
1731 );
1732 Ok(())
1733}
1734
1735pub fn validate_region(bounds: GeoBounds) -> Result<()> {
1736 let area = (bounds.east - bounds.west) * (bounds.north - bounds.south);
1737 ensure!(
1738 bounds.is_valid(),
1739 "survey region has invalid lon/lat bounds"
1740 );
1741 ensure!(
1742 area <= MAX_REGION_DEG2,
1743 "survey region spans {area:.2} square degrees; limit is {MAX_REGION_DEG2:.2}"
1744 );
1745 Ok(())
1746}
1747
1748fn validate_project(project: &Path) -> Result<()> {
1749 ensure!(
1750 project.join("trailgen.toml").is_file(),
1751 "{} is not a trailgen project",
1752 project.display()
1753 );
1754 Ok(())
1755}
1756
1757fn region_key(bounds: GeoBounds) -> String {
1758 let digest = Sha256::digest(
1759 format!(
1760 "trail-region-v1:{:.8}:{:.8}:{:.8}:{:.8}",
1761 bounds.west, bounds.south, bounds.east, bounds.north
1762 )
1763 .as_bytes(),
1764 );
1765 digest[..12]
1766 .iter()
1767 .fold(String::with_capacity(24), |mut key, byte| {
1768 write!(key, "{byte:02x}").expect("write to string");
1769 key
1770 })
1771}
1772
1773#[must_use]
1774pub fn live_bounds(regions: &[SurveyRegion]) -> Option<GeoBounds> {
1775 regions
1776 .iter()
1777 .map(|region| region.bounds)
1778 .reduce(|left, right| {
1779 GeoBounds::new(
1780 left.west.min(right.west),
1781 left.south.min(right.south),
1782 left.east.max(right.east),
1783 left.north.max(right.north),
1784 )
1785 })
1786}
1787
1788fn read_graph_law(project: &Path) -> Result<GraphLaw> {
1789 let path = project.join("trailgen.toml");
1790 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1791 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
1792}
1793
1794pub fn project_config(project: &Path) -> Result<TrailDataConfig> {
1796 #[derive(Deserialize)]
1797 struct ProjectConfig {
1798 #[serde(default)]
1799 trail_data: TrailDataConfig,
1800 #[serde(default)]
1801 area: Option<GeoBounds>,
1802 }
1803
1804 let path = project.join("trailgen.toml");
1805 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1806 let document =
1807 toml::from_str::<toml::Value>(&raw).with_context(|| format!("parse {}", path.display()))?;
1808 let parsed = toml::from_str::<ProjectConfig>(&raw)
1809 .with_context(|| format!("parse {}", path.display()))?;
1810 let mut config = parsed.trail_data;
1811 if config.providers == legacy_automatic_provider_ids() {
1812 config.providers = automatic_provider_ids();
1813 }
1814 let mut names = std::mem::take(&mut config.region_names);
1815 for region in &mut config.regions {
1816 validate_region(region.bounds)?;
1817 let legacy_id = region.id.clone();
1818 region.id = region_key(region.bounds);
1819 if legacy_id != region.id
1820 && let Some(name) = names.remove(&legacy_id)
1821 {
1822 let _replaced = names.insert(region.id.clone(), name);
1823 }
1824 }
1825 config.region_names = names;
1826 let legacy_place = document
1827 .get("trail_data")
1828 .and_then(|trail_data| trail_data.get("place"))
1829 .and_then(toml::Value::as_str)
1830 .is_some_and(|place| !place.trim().is_empty());
1831 if config.regions.is_empty()
1832 && legacy_place
1833 && let Some(bounds) = parsed.area
1834 {
1835 config.regions.push(SurveyRegion::new(bounds)?);
1836 }
1837 config.managed |= legacy_place || !config.regions.is_empty();
1838 validate_config(&config)?;
1839 Ok(config)
1840}
1841
1842pub fn indexed_summary(project: &Path) -> Result<Option<Summary>> {
1844 let path = project.join(TRAIL_INDEX);
1845 let raw = match fs::read_to_string(&path) {
1846 Ok(raw) => raw,
1847 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1848 Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
1849 };
1850 let Ok(index) = serde_json::from_str::<TrailIndex>(&raw) else {
1851 return Ok(None);
1852 };
1853 if index.schema != INDEX_SCHEMA {
1854 return Ok(None);
1855 }
1856 let config = project_config(project)?;
1857 if !index_matches_config(&index, &config) {
1858 return Ok(None);
1859 }
1860 let graph = match fs::metadata(project.join(GRAPH_CACHE)) {
1861 Ok(graph) => graph,
1862 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1863 Err(err) => return Err(err).context("inspect cached trail graph"),
1864 };
1865 if !graph.is_file()
1866 || graph.len() != index.graph.bytes
1867 || !project.join(CONFLATION_REPORT).is_file()
1868 {
1869 return Ok(None);
1870 }
1871 if index.summary.elevation_tiles != index.elevation.len() {
1872 return Ok(None);
1873 }
1874 for receipt in &index.elevation {
1875 let bytes = match fs::read(project.join(&receipt.raw_path)) {
1876 Ok(bytes) => bytes,
1877 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1878 Err(err) => return Err(err).context("read terrain receipt"),
1879 };
1880 if fingerprint(&bytes) != receipt.raw {
1881 return Ok(None);
1882 }
1883 }
1884 Ok(Some(index.summary))
1885}
1886
1887pub fn configure_project(project: &Path, trail_data: &TrailDataConfig) -> Result<()> {
1889 validate_config(trail_data)?;
1890 let path = project.join("trailgen.toml");
1891 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1892 let mut config =
1893 toml::from_str::<toml::Value>(&raw).with_context(|| format!("parse {}", path.display()))?;
1894 config
1895 .as_table_mut()
1896 .context("trailgen.toml root must be a table")?
1897 .insert("trail_data".to_owned(), toml::Value::try_from(trail_data)?);
1898 write_atomic(&path, toml::to_string_pretty(&config)?.as_bytes())
1899}
1900
1901pub fn name_region(project: &Path, id: &str, name: &str) -> Result<TrailDataConfig> {
1903 validate_project(project)?;
1904 let mut config = project_config(project)?;
1905 ensure!(
1906 config.regions.iter().any(|region| region.id == id),
1907 "project has no survey region {id}"
1908 );
1909 let name = name.trim();
1910 if name.is_empty() {
1911 let _old = config.region_names.remove(id);
1912 } else {
1913 validate_region_name(name)?;
1914 let _old = config.region_names.insert(id.to_owned(), name.to_owned());
1915 }
1916 configure_project(project, &config)?;
1917 Ok(config)
1918}
1919
1920fn validate_config(config: &TrailDataConfig) -> Result<()> {
1921 let mut ids = BTreeSet::new();
1922 for region in &config.regions {
1923 region.validate()?;
1924 ensure!(
1925 ids.insert(®ion.id),
1926 "duplicate survey region {}",
1927 region.id
1928 );
1929 }
1930 for (id, name) in &config.region_names {
1931 ensure!(
1932 ids.contains(id),
1933 "map-area name refers to unknown region {id}"
1934 );
1935 validate_region_name(name)?;
1936 }
1937 let providers = config.providers.iter().collect::<BTreeSet<_>>();
1938 ensure!(
1939 !providers.is_empty(),
1940 "trail data needs at least one provider"
1941 );
1942 ensure!(
1943 providers.len() == config.providers.len(),
1944 "trail data contains duplicate providers"
1945 );
1946 Ok(())
1947}
1948
1949fn validate_region_name(name: &str) -> Result<()> {
1950 ensure!(!name.trim().is_empty(), "map-area name is empty");
1951 ensure!(name.chars().count() <= 80, "map-area name is too long");
1952 ensure!(
1953 !name.chars().any(char::is_control),
1954 "map-area name contains control characters"
1955 );
1956 Ok(())
1957}
1958
1959fn store_area(project: &Path, bounds: Option<GeoBounds>) -> Result<()> {
1960 let path = project.join("trailgen.toml");
1961 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1962 let mut config =
1963 toml::from_str::<toml::Value>(&raw).with_context(|| format!("parse {}", path.display()))?;
1964 let table = config
1965 .as_table_mut()
1966 .context("trailgen.toml root must be a table")?;
1967 if let Some(bounds) = bounds {
1968 table.insert("area".to_owned(), toml::Value::try_from(bounds)?);
1969 } else {
1970 table.remove("area");
1971 }
1972 write_atomic(&path, toml::to_string_pretty(&config)?.as_bytes())
1973}
1974
1975fn write_source_manifest(
1976 project: &Path,
1977 sources: &[ProviderSource],
1978 inventory: &Inventory,
1979 bounds: GeoBounds,
1980) -> Result<()> {
1981 let manifest_path = project.join(SOURCE_MANIFEST);
1982 let mut manifest = match fs::read_to_string(&manifest_path) {
1983 Ok(raw) => serde_json::from_str::<SourceManifest>(&raw)
1984 .with_context(|| format!("parse {}", manifest_path.display()))?,
1985 Err(err) if err.kind() == std::io::ErrorKind::NotFound => SourceManifest {
1986 adapters: Vec::new(),
1987 recommendations: Vec::new(),
1988 coverage: Vec::new(),
1989 candidates: Vec::new(),
1990 },
1991 Err(err) => {
1992 return Err(err).with_context(|| format!("read {}", manifest_path.display()));
1993 }
1994 };
1995 manifest
1996 .candidates
1997 .retain(|candidate| !is_live_provider_candidate(candidate));
1998 for source in sources {
1999 let raw_path = source.raw_relative.display().to_string();
2000 let region = source.region.bounds;
2001 let origin = format!(
2002 "provider:{}:{} bbox={},{},{},{}",
2003 source.descriptor.id,
2004 source.origin,
2005 region.west,
2006 region.south,
2007 region.east,
2008 region.north
2009 );
2010 manifest.candidates.push(candidate(
2011 &raw_path,
2012 SourceKind::TrailNetwork,
2013 if source.descriptor.id.as_str() == "osm" {
2014 "osm-xml-network"
2015 } else {
2016 "geojson-network"
2017 },
2018 &source.fingerprint,
2019 &origin,
2020 ));
2021 if source.descriptor.id.as_str() == "osm" && inventory.road_features > 0 {
2022 manifest.candidates.push(candidate(
2023 &raw_path,
2024 SourceKind::Road,
2025 "osm-road-context",
2026 &source.fingerprint,
2027 &origin,
2028 ));
2029 }
2030 if source.descriptor.id.as_str() == "osm" && inventory.waterway_features > 0 {
2031 manifest.candidates.push(candidate(
2032 &raw_path,
2033 SourceKind::Hydrology,
2034 "osm-hydrology-context",
2035 &source.fingerprint,
2036 &origin,
2037 ));
2038 }
2039 }
2040 manifest.candidates.sort_by(|left, right| {
2041 (&left.path, left.kind, &left.adapter_id).cmp(&(&right.path, right.kind, &right.adapter_id))
2042 });
2043 manifest.adapters = adapter_registry();
2044 manifest.recommendations = discovery_recommendations(Some(bounds));
2045 manifest.coverage = source_coverage(
2046 &manifest.adapters,
2047 &manifest.recommendations,
2048 &manifest.candidates,
2049 );
2050 write_json_atomic(manifest_path, &manifest)
2051}
2052
2053fn clear_corpus(project: &Path) -> Result<()> {
2054 clear_graph_auxiliaries(project)?;
2055 remove_files(project, &[TRAIL_INDEX, GRAPH_CACHE, CONFLATION_REPORT])?;
2056 store_area(project, None)?;
2057 let manifest_path = project.join(SOURCE_MANIFEST);
2058 match fs::read_to_string(&manifest_path) {
2059 Ok(raw) => {
2060 let mut manifest = serde_json::from_str::<SourceManifest>(&raw)
2061 .with_context(|| format!("parse {}", manifest_path.display()))?;
2062 manifest
2063 .candidates
2064 .retain(|candidate| !is_live_provider_candidate(candidate));
2065 manifest.adapters = adapter_registry();
2066 manifest.recommendations = discovery_recommendations(None);
2067 manifest.coverage = source_coverage(
2068 &manifest.adapters,
2069 &manifest.recommendations,
2070 &manifest.candidates,
2071 );
2072 write_json_atomic(manifest_path, &manifest)?;
2073 }
2074 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2075 Err(err) => {
2076 return Err(err).with_context(|| format!("read {}", manifest_path.display()));
2077 }
2078 }
2079 Ok(())
2080}
2081
2082fn remove_files(project: &Path, relatives: &[&str]) -> Result<()> {
2083 for relative in relatives {
2084 match fs::remove_file(project.join(relative)) {
2085 Ok(()) => {}
2086 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2087 Err(err) => return Err(err).with_context(|| format!("remove {relative}")),
2088 }
2089 }
2090 Ok(())
2091}
2092
2093fn reap_provider_receipts(
2094 project: &Path,
2095 sources: &[ProviderSource],
2096 descriptors: &[ProviderDescriptor],
2097) -> Result<()> {
2098 let desired = sources
2099 .iter()
2100 .flat_map(|source| {
2101 let raw = project.join(&source.raw_relative);
2102 [
2103 raw.clone(),
2104 raw.with_extension(source.descriptor.request_extension),
2105 raw.with_extension("json"),
2106 ]
2107 })
2108 .collect::<BTreeSet<_>>();
2109 for descriptor in descriptors {
2110 let root = project.join("sources").join(descriptor.id.as_str());
2111 let entries = match fs::read_dir(&root) {
2112 Ok(entries) => entries,
2113 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2114 Err(error) => {
2115 return Err(error).with_context(|| format!("inspect {}", root.display()));
2116 }
2117 };
2118 let owned_extensions = [descriptor.extension, descriptor.request_extension, "json"];
2119 for entry in entries {
2120 let path = entry?.path();
2121 let owned = path.is_file()
2122 && path
2123 .file_stem()
2124 .and_then(|stem| stem.to_str())
2125 .is_some_and(region_receipt_stem)
2126 && path
2127 .extension()
2128 .and_then(|extension| extension.to_str())
2129 .is_some_and(|extension| owned_extensions.contains(&extension));
2130 if owned && !desired.contains(&path) {
2131 fs::remove_file(&path)
2132 .with_context(|| format!("reap obsolete receipt {}", path.display()))?;
2133 }
2134 }
2135 }
2136 Ok(())
2137}
2138
2139fn region_receipt_stem(stem: &str) -> bool {
2140 stem.len() == 24 && stem.bytes().all(|byte| byte.is_ascii_hexdigit())
2141}
2142
2143fn is_live_provider_candidate(candidate: &SourceCandidate) -> bool {
2144 candidate
2145 .origin
2146 .as_deref()
2147 .is_some_and(|origin| origin.starts_with("provider:") || origin.starts_with("overpass:"))
2148}
2149
2150fn candidate(
2151 path: &str,
2152 kind: SourceKind,
2153 adapter_id: &str,
2154 fingerprint: &SourceFingerprint,
2155 origin: &str,
2156) -> SourceCandidate {
2157 SourceCandidate {
2158 path: path.to_owned(),
2159 kind,
2160 adapter_id: adapter_id.to_owned(),
2161 origin: Some(origin.to_owned()),
2162 fingerprint: Some(fingerprint.clone()),
2163 }
2164}
2165
2166fn parse_coordinate(raw: &str, name: &str) -> Result<f64> {
2167 raw.parse::<f64>()
2168 .with_context(|| format!("place search returned an invalid {name}"))
2169}
2170
2171fn user_agent(task: &str) -> String {
2172 format!(
2173 "adequate-trailgen/{} {task} ({})",
2174 env!("CARGO_PKG_VERSION"),
2175 env!("CARGO_PKG_REPOSITORY")
2176 )
2177}
2178
2179pub fn provider_client(task: &str, timeout: Duration) -> Result<reqwest::blocking::Client> {
2180 let builder = reqwest::blocking::Client::builder()
2181 .timeout(timeout)
2182 .user_agent(user_agent(task));
2183 #[cfg(unix)]
2184 let builder = if let Some(raw) = env::var_os("TRAILGEN_HTTP_UNIX_SOCKET") {
2185 let socket = PathBuf::from(raw);
2186 ensure!(
2187 socket.is_absolute(),
2188 "TRAILGEN_HTTP_UNIX_SOCKET must name an absolute path"
2189 );
2190 builder.unix_socket(socket)
2191 } else {
2192 builder
2193 };
2194 #[cfg(not(unix))]
2195 ensure!(
2196 env::var_os("TRAILGEN_HTTP_UNIX_SOCKET").is_none(),
2197 "TRAILGEN_HTTP_UNIX_SOCKET is unavailable on this platform"
2198 );
2199 builder.build().context("build HTTP provider client")
2200}
2201
2202fn fingerprint(bytes: &[u8]) -> SourceFingerprint {
2203 SourceFingerprint {
2204 bytes: bytes.len() as u64,
2205 sha256: format!("{:x}", Sha256::digest(bytes)),
2206 }
2207}
2208
2209pub fn store_graph(project: &Path, graph: &WalkGraph) -> Result<()> {
2211 let graph_cache = encode_graph(graph)?;
2212 clear_graph_auxiliaries(project)?;
2213 write_atomic(
2214 &project.join(GRAPH_GEOJSON),
2215 &serde_json::to_vec_pretty(&geojson::graph_to_geojson(graph))?,
2216 )?;
2217 write_atomic(
2218 &project.join("cache/edges.csv"),
2219 graph_edges_csv(graph).as_bytes(),
2220 )?;
2221 write_atomic(
2222 &project.join("cache/vertices.csv"),
2223 graph_vertices_csv(graph).as_bytes(),
2224 )?;
2225 write_atomic(&project.join(GRAPH_CACHE), &graph_cache)
2226}
2227
2228fn clear_graph_auxiliaries(project: &Path) -> Result<()> {
2229 remove_files(project, GRAPH_AUXILIARIES)
2230}
2231
2232fn graph_vertices_csv(graph: &WalkGraph) -> String {
2233 let mut out = String::from("vertex_id,junction_id,lon,lat,elevation_m,wkt\n");
2234 for vertex in &graph.vertices {
2235 let Coord { lon, lat, ele } = vertex.coord;
2236 writeln!(
2237 out,
2238 "{},{},{lon:.7},{lat:.7},{},{}",
2239 vertex.id.0,
2240 csv_cell(vertex.junction.as_ref().map_or("", |key| key.0.as_str())),
2241 csv_f64(ele),
2242 csv_cell(&point_wkt(vertex.coord))
2243 )
2244 .expect("write to string");
2245 }
2246 out
2247}
2248
2249fn graph_edges_csv(graph: &WalkGraph) -> String {
2250 let mut out = String::from(
2251 "edge_id,from_vertex,to_vertex,travel,length_m,ascent_m,descent_m,grade_abs_mean,grade_abs_max,sustained_steep_m,hill_slope_deg,way_kind,realm,geometry_claim,crossing_control,trail_standing,trail_marking,terrain,surface,terrain_confidence,terrain_evidence,access,access_confidence,access_provenance,road_exposure,confidence,lower_limb_load_forward_km,moving_time_forward_s,lower_limb_load_reverse_km,moving_time_reverse_s,seed_count,seed_provenance,elevation_provenance,road_crossings,water_crossings,provenance,wkt\n",
2252 );
2253 for edge in &graph.edges {
2254 let (roads, water) = edge_crossing_counts(edge);
2255 let row = [
2256 edge.id.0.to_string(),
2257 edge.a.0.to_string(),
2258 edge.b.0.to_string(),
2259 edge_travel_tag(edge.attr.travel).to_owned(),
2260 format!("{:.3}", edge.attr.length_m),
2261 format!("{:.3}", edge.attr.ascent_m),
2262 format!("{:.3}", edge.attr.descent_m),
2263 format!("{:.6}", edge.attr.grade_abs_mean),
2264 format!("{:.6}", edge.attr.grade_abs_max),
2265 format!("{:.3}", edge.attr.sustained_steep_m),
2266 csv_f64(edge.attr.hill_slope_deg),
2267 way_kind_tag(edge.attr.way_kind).to_owned(),
2268 way_realm_tag(edge.attr.realm).to_owned(),
2269 geometry_claim_tag(edge.attr.geometry_claim).to_owned(),
2270 crossing_control_tag(edge.attr.crossing_control).to_owned(),
2271 trail_standing_tag(edge.attr.standing).to_owned(),
2272 trail_marking_tag(edge.attr.marking).to_owned(),
2273 terrain_tag(edge.attr.terrain).to_owned(),
2274 csv_cell(edge.attr.surface.as_deref().unwrap_or("")),
2275 format!("{:.6}", edge.attr.terrain_confidence),
2276 csv_cell(&terrain_evidence_summary(&edge.attr.terrain_evidence)),
2277 access_tag(edge.attr.access).to_owned(),
2278 format!("{:.6}", edge.attr.access_confidence),
2279 csv_cell(&provenance_summary(&edge.attr.access_provenance)),
2280 format!("{:.6}", edge.attr.road_exposure),
2281 format!("{:.6}", edge.attr.confidence),
2282 format!("{:.6}", edge.attr.traversal.forward.lower_limb_load_km),
2283 format!("{:.3}", edge.attr.traversal.forward.moving_time_s),
2284 format!("{:.6}", edge.attr.traversal.reverse.lower_limb_load_km),
2285 format!("{:.3}", edge.attr.traversal.reverse.moving_time_s),
2286 edge.attr.seed_count.to_string(),
2287 csv_cell(&provenance_summary(&edge.attr.seed_provenance)),
2288 csv_cell(&provenance_summary(&edge.attr.elevation_provenance)),
2289 roads.to_string(),
2290 water.to_string(),
2291 csv_cell(&provenance_summary(&edge.attr.provenance)),
2292 csv_cell(&line_wkt(&edge.geometry)),
2293 ];
2294 writeln!(out, "{}", row.join(",")).expect("write to string");
2295 }
2296 out
2297}
2298
2299fn terrain_evidence_summary(evidence: &[TerrainEvidence]) -> String {
2300 evidence
2301 .iter()
2302 .map(|evidence| {
2303 let mut summary = format!(
2304 "{}:{:.0}%:{}",
2305 terrain_tag(evidence.terrain),
2306 evidence.confidence * 100.0,
2307 evidence.rationale
2308 );
2309 if let Some(provenance) = &evidence.provenance {
2310 write!(summary, ":{}", provenance_csv_label(provenance)).expect("write to string");
2311 }
2312 summary
2313 })
2314 .collect::<Vec<_>>()
2315 .join("|")
2316}
2317
2318fn edge_crossing_counts(edge: &Edge) -> (u32, u32) {
2319 edge.attr
2320 .crossings
2321 .iter()
2322 .fold((0, 0), |(roads, water), crossing| match crossing.kind {
2323 CrossingKind::Road => (roads + crossing.count, water),
2324 CrossingKind::Water => (roads, water + crossing.count),
2325 })
2326}
2327
2328fn provenance_summary(provenance: &[Provenance]) -> String {
2329 provenance
2330 .iter()
2331 .map(provenance_csv_label)
2332 .collect::<Vec<_>>()
2333 .join("|")
2334}
2335
2336fn provenance_csv_label(provenance: &Provenance) -> String {
2337 let mut label = provenance.source.clone();
2338 if let Some(layer) = &provenance.layer {
2339 write!(label, ":{layer}").expect("write to string");
2340 }
2341 if let Some(source_id) = &provenance.source_id {
2342 write!(label, ":{source_id}").expect("write to string");
2343 }
2344 label
2345}
2346
2347fn line_wkt(line: &LineString) -> String {
2348 format!(
2349 "LINESTRING Z ({})",
2350 line.points
2351 .iter()
2352 .map(coord_wkt_tuple)
2353 .collect::<Vec<_>>()
2354 .join(", ")
2355 )
2356}
2357
2358fn point_wkt(coord: Coord) -> String {
2359 format!("POINT Z ({})", coord_wkt_tuple(&coord))
2360}
2361
2362fn coord_wkt_tuple(coord: &Coord) -> String {
2363 format!(
2364 "{:.7} {:.7} {:.3}",
2365 coord.lon,
2366 coord.lat,
2367 coord.ele.unwrap_or(0.0)
2368 )
2369}
2370
2371fn csv_cell(value: &str) -> String {
2372 if value.contains([',', '"', '\n', '\r']) {
2373 format!("\"{}\"", value.replace('"', "\"\""))
2374 } else {
2375 value.to_owned()
2376 }
2377}
2378
2379fn csv_f64(value: Option<f64>) -> String {
2380 value.map_or_else(String::new, |value| format!("{value:.3}"))
2381}
2382
2383const fn way_kind_tag(class: WayKind) -> &'static str {
2384 match class {
2385 WayKind::Unknown => "unknown",
2386 WayKind::Path => "path",
2387 WayKind::Footway => "footway",
2388 WayKind::Sidewalk => "sidewalk",
2389 WayKind::Crossing => "crossing",
2390 WayKind::Track => "track",
2391 WayKind::ServiceRoad => "service",
2392 WayKind::PedestrianStreet => "pedestrian",
2393 WayKind::Steps => "steps",
2394 WayKind::Bridleway => "bridleway",
2395 WayKind::Bushwhack => "bushwhack",
2396 WayKind::Roadway => "road",
2397 WayKind::Cycleway => "cycleway",
2398 }
2399}
2400
2401const fn trail_standing_tag(standing: TrailStanding) -> &'static str {
2402 match standing {
2403 TrailStanding::Unknown => "unknown",
2404 TrailStanding::Established => "established",
2405 TrailStanding::Unmaintained => "unmaintained",
2406 TrailStanding::Informal => "informal",
2407 TrailStanding::Historical => "historical",
2408 }
2409}
2410
2411const fn way_realm_tag(realm: WayRealm) -> &'static str {
2412 match realm {
2413 WayRealm::Recreational => "recreational",
2414 WayRealm::Connector => "connector",
2415 WayRealm::Urban => "urban",
2416 }
2417}
2418
2419const fn geometry_claim_tag(claim: GeometryClaim) -> &'static str {
2420 match claim {
2421 GeometryClaim::Surveyed => "surveyed",
2422 GeometryClaim::CenterlineProxy => "centerline-proxy",
2423 }
2424}
2425
2426const fn crossing_control_tag(control: CrossingControl) -> &'static str {
2427 match control {
2428 CrossingControl::None => "none",
2429 CrossingControl::Uncontrolled => "uncontrolled",
2430 CrossingControl::Marked => "marked",
2431 CrossingControl::Signals => "signals",
2432 CrossingControl::GradeSeparated => "grade-separated",
2433 }
2434}
2435
2436const fn trail_marking_tag(marking: TrailMarking) -> &'static str {
2437 match marking {
2438 TrailMarking::Unknown => "unknown",
2439 TrailMarking::Marked => "marked",
2440 TrailMarking::Unmarked => "unmarked",
2441 }
2442}
2443
2444const fn terrain_tag(terrain: Terrain) -> &'static str {
2445 match terrain {
2446 Terrain::Unknown => "unknown",
2447 Terrain::Trail => "trail",
2448 Terrain::Forest => "forest",
2449 Terrain::Alpine => "alpine",
2450 Terrain::Talus => "talus",
2451 Terrain::Scramble => "scramble",
2452 Terrain::Pavement => "pavement",
2453 Terrain::Road => "road",
2454 Terrain::Water => "water",
2455 }
2456}
2457
2458const fn access_tag(access: Access) -> &'static str {
2459 match access {
2460 Access::Unknown => "unknown",
2461 Access::Open => "open",
2462 Access::Restricted => "restricted",
2463 Access::Closed => "closed",
2464 Access::Private => "private",
2465 }
2466}
2467
2468const fn edge_travel_tag(travel: EdgeTravel) -> &'static str {
2469 match travel {
2470 EdgeTravel::Both => "both",
2471 EdgeTravel::Forward => "forward",
2472 EdgeTravel::Backward => "backward",
2473 }
2474}
2475
2476fn read_bounded(mut response: Response, limit: u64, label: &str) -> Result<Vec<u8>> {
2477 if let Some(length) = response.content_length() {
2478 ensure!(
2479 length <= limit,
2480 "{label} is {length} bytes; limit is {limit}"
2481 );
2482 }
2483 let mut bytes = Vec::new();
2484 response
2485 .by_ref()
2486 .take(limit + 1)
2487 .read_to_end(&mut bytes)
2488 .with_context(|| format!("read {label}"))?;
2489 ensure!(bytes.len() as u64 <= limit, "{label} exceeds {limit} bytes");
2490 Ok(bytes)
2491}
2492
2493fn write_json_atomic(path: impl AsRef<Path>, value: &impl Serialize) -> Result<()> {
2494 write_atomic(path.as_ref(), serde_json::to_vec_pretty(value)?.as_slice())
2495}
2496
2497fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
2498 let parent = path.parent().context("artifact path has no parent")?;
2499 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
2500 let mut staging = Staging::raise(path)?;
2501 staging
2502 .file_mut()?
2503 .write_all(bytes)
2504 .with_context(|| format!("write {}", staging.path.display()))?;
2505 staging
2506 .file_mut()?
2507 .sync_all()
2508 .with_context(|| format!("flush {}", staging.path.display()))?;
2509 staging.commit(path)
2510}
2511
2512struct Staging {
2513 path: PathBuf,
2514 file: Option<File>,
2515}
2516
2517impl Staging {
2518 fn raise(target: &Path) -> Result<Self> {
2519 let extension = target
2520 .extension()
2521 .and_then(|extension| extension.to_str())
2522 .unwrap_or("");
2523 for nonce in 0..64 {
2524 let path = target.with_extension(format!(
2525 "{extension}.{}.{}.partial",
2526 std::process::id(),
2527 nonce
2528 ));
2529 match OpenOptions::new().write(true).create_new(true).open(&path) {
2530 Ok(file) => {
2531 return Ok(Self {
2532 path,
2533 file: Some(file),
2534 });
2535 }
2536 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
2537 Err(err) => {
2538 return Err(err)
2539 .with_context(|| format!("raise staging file {}", path.display()));
2540 }
2541 }
2542 }
2543 anyhow::bail!("staging namespace exhausted beside {}", target.display())
2544 }
2545
2546 fn file_mut(&mut self) -> Result<&mut File> {
2547 self.file.as_mut().context("staging file already sealed")
2548 }
2549
2550 fn commit(mut self, target: &Path) -> Result<()> {
2551 drop(self.file.take());
2552 fs::rename(&self.path, target).with_context(|| format!("commit {}", target.display()))?;
2553 self.path.clear();
2554 Ok(())
2555 }
2556}
2557
2558impl Drop for Staging {
2559 fn drop(&mut self) {
2560 if !self.path.as_os_str().is_empty() {
2561 let _partial = fs::remove_file(&self.path);
2562 }
2563 }
2564}
2565
2566#[cfg(test)]
2567mod tests {
2568 use super::*;
2569 use std::{cell::Cell, rc::Rc};
2570 use trailgen_core::JunctionPolicy;
2571
2572 #[derive(Clone)]
2573 struct FixedPlace;
2574
2575 impl PlaceIndex for FixedPlace {
2576 fn locate_us(&self, query: &str) -> Result<Place> {
2577 Ok(Place {
2578 query: query.to_owned(),
2579 label: "Harriman State Park, New York, United States".to_owned(),
2580 center: Coord::new(-74.124_792_4, 41.230_375_5),
2581 license: "OpenStreetMap contributors".to_owned(),
2582 provider: "fixture".to_owned(),
2583 })
2584 }
2585 }
2586
2587 #[derive(Clone, Default)]
2588 struct FixedProvider {
2589 calls: Rc<Cell<usize>>,
2590 fail_at: Option<usize>,
2591 }
2592
2593 impl NetworkProvider for FixedProvider {
2594 fn descriptor(&self) -> ProviderDescriptor {
2595 ProviderDescriptor {
2596 id: ProviderId::new("fixture").unwrap(),
2597 label: "Fixture trails",
2598 adapter_revision: 1,
2599 precedence: 0,
2600 extension: "osm",
2601 request_extension: "request",
2602 }
2603 }
2604
2605 fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
2606 assert!(bounds.is_valid());
2607 let call = self.calls.get() + 1;
2608 self.calls.set(call);
2609 if self.fail_at == Some(call) {
2610 anyhow::bail!("fixture acquisition failure");
2611 }
2612 Ok(ProviderPayload {
2613 bytes: include_bytes!("../tests/fixtures/tiny-overpass.osm").to_vec(),
2614 request: "fixture request".to_owned(),
2615 origin: "fixture://overpass".to_owned(),
2616 })
2617 }
2618
2619 fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
2620 let merged = merge_osm(shards)?;
2621 Ok(NormalizedNetwork {
2622 drafts: osm::network_from_str(&merged)?,
2623 context: osm::context_overlays_from_str(&merged)?,
2624 })
2625 }
2626 }
2627
2628 #[derive(Clone, Default)]
2629 struct FixedUsgs {
2630 calls: Rc<Cell<usize>>,
2631 }
2632
2633 impl NetworkProvider for FixedUsgs {
2634 fn descriptor(&self) -> ProviderDescriptor {
2635 UsgsNationalTrails::default().descriptor()
2636 }
2637
2638 fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
2639 assert!(bounds.is_valid());
2640 self.calls.set(self.calls.get() + 1);
2641 Ok(ProviderPayload {
2642 bytes: include_bytes!("../tests/fixtures/tiny-usgs-trails.geojson").to_vec(),
2643 request: "fixture USGS request".to_owned(),
2644 origin: "fixture://usgs-national-trails".to_owned(),
2645 })
2646 }
2647
2648 fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
2649 UsgsNationalTrails::default().normalize(shards)
2650 }
2651 }
2652
2653 fn fixed_surveyor() -> (Surveyor<FixedPlace>, Rc<Cell<usize>>) {
2654 let provider = FixedProvider::default();
2655 let calls = Rc::clone(&provider.calls);
2656 (Surveyor::new(FixedPlace, provider), calls)
2657 }
2658
2659 fn fixed_multi_surveyor() -> (Surveyor<FixedPlace>, Rc<Cell<usize>>, Rc<Cell<usize>>) {
2660 let osm = FixedProvider::default();
2661 let usgs = FixedUsgs::default();
2662 let osm_calls = Rc::clone(&osm.calls);
2663 let usgs_calls = Rc::clone(&usgs.calls);
2664 (
2665 Surveyor::with_providers(FixedPlace, vec![Box::new(osm), Box::new(usgs)]),
2666 osm_calls,
2667 usgs_calls,
2668 )
2669 }
2670
2671 #[test]
2672 fn place_debug_frontend_produces_a_bounded_region() -> Result<()> {
2673 let place = FixedPlace.locate_us("Harriman")?;
2674 let bounds = bounds_around(&place, 20.0)?;
2675 assert!(bounds.is_valid());
2676 assert!((bounds.north - bounds.south) < 0.37);
2677 assert!((bounds.east - bounds.west) < 0.49);
2678 Ok(())
2679 }
2680
2681 #[test]
2682 fn region_receipts_round_trip_without_spatial_drift() -> Result<()> {
2683 let expected = SurveyRegion::new(bounds_around(
2684 &FixedPlace.locate_us("Harriman")?,
2685 DEFAULT_RADIUS_KM,
2686 )?)?;
2687 let encoded = serde_json::to_vec(&expected)?;
2688 let decoded = serde_json::from_slice::<SurveyRegion>(&encoded)?;
2689 assert_eq!(decoded, expected);
2690 Ok(())
2691 }
2692
2693 #[test]
2694 fn map_area_names_are_metadata_not_corpus_identity() -> Result<()> {
2695 let temp = tempfile::tempdir()?;
2696 let project = temp.path();
2697 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2698 let (surveyor, _) = fixed_surveyor();
2699 let summary = surveyor.add_region(
2700 project,
2701 GeoBounds::new(-74.130, 41.225, -74.120, 41.235),
2702 drop,
2703 )?;
2704 let id = summary.regions[0].id.clone();
2705
2706 let named = name_region(project, &id, " Wharton State Forest ")?;
2707
2708 assert_eq!(
2709 named.region_names.get(&id).map(String::as_str),
2710 Some("Wharton State Forest")
2711 );
2712 assert_eq!(indexed_summary(project)?, Some(summary));
2713
2714 let unnamed = name_region(project, &id, " ")?;
2715 assert!(!unnamed.region_names.contains_key(&id));
2716 Ok(())
2717 }
2718
2719 #[test]
2720 fn project_read_rekeys_provider_bound_legacy_regions() -> Result<()> {
2721 let temp = tempfile::tempdir()?;
2722 fs::write(
2723 temp.path().join("trailgen.toml"),
2724 "[trail_data]\nmanaged = true\nproviders = ['osm']\n\
2725 [[trail_data.regions]]\nid = 'legacy-osm-receipt'\n\
2726 [trail_data.regions.bounds]\nwest = -74.2\nsouth = 41.1\neast = -74.0\nnorth = 41.35\n",
2727 )?;
2728
2729 let config = project_config(temp.path())?;
2730
2731 assert_eq!(config.regions[0].id, region_key(config.regions[0].bounds));
2732 assert_ne!(config.regions[0].id, "legacy-osm-receipt");
2733 Ok(())
2734 }
2735
2736 #[test]
2737 fn project_read_migrates_the_old_automatic_provider_batch() -> Result<()> {
2738 let temp = tempfile::tempdir()?;
2739 fs::write(
2740 temp.path().join("trailgen.toml"),
2741 "[trail_data]\nmanaged = true\nproviders = ['osm', 'usgs-national-trails']\n",
2742 )?;
2743
2744 let config = project_config(temp.path())?;
2745
2746 assert_eq!(config.providers, automatic_provider_ids());
2747 assert!(
2748 config
2749 .providers
2750 .contains(&ProviderId::new("ny-state-parks")?)
2751 );
2752 assert!(
2753 config
2754 .providers
2755 .contains(&ProviderId::new("texas-state-parks")?)
2756 );
2757 Ok(())
2758 }
2759
2760 #[test]
2761 fn refresh_seals_canonical_region_identity_onto_disk() -> Result<()> {
2762 let temp = tempfile::tempdir()?;
2763 let project = temp.path();
2764 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2765 let (surveyor, calls) = fixed_surveyor();
2766 let indexed = surveyor.survey(project, "Harriman", 20.0, drop)?;
2767 let canonical = &indexed.regions[0].id;
2768 let path = project.join("trailgen.toml");
2769 let legacy = fs::read_to_string(&path)?.replace(canonical, "legacy-osm-receipt");
2770 fs::write(&path, legacy)?;
2771
2772 let refreshed = surveyor.refresh(project, drop)?.context("indexed corpus")?;
2773
2774 assert!(refreshed.reused);
2775 assert_eq!(calls.get(), 1);
2776 let persisted = fs::read_to_string(path)?;
2777 assert!(persisted.contains(canonical));
2778 assert!(!persisted.contains("legacy-osm-receipt"));
2779 Ok(())
2780 }
2781
2782 #[test]
2783 fn overpass_queries_are_profiled_and_bbox_scoped() {
2784 let area = GeoBounds::new(-74.2, 41.1, -74.0, 41.35);
2785 let query = overpass_query(OsmProfile::All, area, 90);
2786 assert!(query.starts_with("[out:xml][timeout:90];"));
2787 assert!(query.contains("(41.1,-74.2,41.35,-74)"));
2788 assert!(query.contains(r#"["waterway"~"#));
2789 assert!(query.contains(r#"rel(bw.trailways)["type"="route"]"#));
2790 assert!(query.contains("way(bn.trailnodes)"));
2791 let trail_seed = query
2792 .split(")->.trailways")
2793 .next()
2794 .expect("query has a trail seed");
2795 assert!(trail_seed.contains(
2796 r#"["highway"~"^(path|footway|cycleway|pedestrian|track|steps|bridleway)$"]"#
2797 ));
2798 assert!(
2799 query.contains("residential"),
2800 "roads remain context evidence"
2801 );
2802 }
2803
2804 #[test]
2805 fn osm_streets_survive_intact_while_finder_gets_only_bounded_connectors() -> Result<()> {
2806 let raw = r#"<osm version="0.6">
2807 <node id="1" lon="0.000" lat="0.000"/><node id="2" lon="0.001" lat="0.000"/>
2808 <node id="3" lon="0.002" lat="0.000"/><node id="4" lon="0.003" lat="0.000"/>
2809 <node id="5" lon="0.002" lat="0.001"/><node id="6" lon="0.002" lat="0.002"/>
2810 <node id="7" lon="0.010" lat="0.000"/><node id="8" lon="0.011" lat="0.000"/>
2811 <node id="9" lon="0.020" lat="0.000"/><node id="10" lon="0.021" lat="0.000"/>
2812 <node id="11" lon="0.041" lat="0.000"/><node id="12" lon="0.042" lat="0.000"/>
2813 <way id="trail-a"><nd ref="1"/><nd ref="2"/><tag k="highway" v="path"/></way>
2814 <way id="bridge"><nd ref="2"/><nd ref="3"/><tag k="highway" v="service"/></way>
2815 <way id="trail-b"><nd ref="3"/><nd ref="4"/><tag k="highway" v="path"/></way>
2816 <way id="street-spur"><nd ref="3"/><nd ref="5"/><nd ref="6"/><tag k="highway" v="residential"/></way>
2817 <way id="street-island"><nd ref="7"/><nd ref="8"/><tag k="highway" v="residential"/></way>
2818 <way id="trail-c"><nd ref="9"/><nd ref="10"/><tag k="highway" v="path"/></way>
2819 <way id="street-too-long"><nd ref="10"/><nd ref="11"/><tag k="highway" v="service"/></way>
2820 <way id="trail-d"><nd ref="11"/><nd ref="12"/><tag k="highway" v="path"/></way>
2821 </osm>"#;
2822
2823 let drafts = classify_osm_realms(osm::network_from_str(raw)?);
2824 let source_ids = drafts
2825 .iter()
2826 .flat_map(|draft| &draft.provenance)
2827 .filter_map(|provenance| provenance.source_id.as_deref())
2828 .collect::<BTreeSet<_>>();
2829
2830 assert_eq!(
2831 source_ids,
2832 BTreeSet::from([
2833 "bridge",
2834 "street-island",
2835 "street-spur",
2836 "street-too-long",
2837 "trail-a",
2838 "trail-b",
2839 "trail-c",
2840 "trail-d",
2841 ])
2842 );
2843 let realm = |source_id| {
2844 drafts
2845 .iter()
2846 .find(|draft| {
2847 draft
2848 .provenance
2849 .iter()
2850 .any(|provenance| provenance.source_id.as_deref() == Some(source_id))
2851 })
2852 .map(|draft| draft.realm)
2853 };
2854 assert_eq!(realm("bridge"), Some(WayRealm::Connector));
2855 assert_eq!(realm("street-spur"), Some(WayRealm::Urban));
2856 assert_eq!(realm("street-island"), Some(WayRealm::Urban));
2857 assert_eq!(realm("street-too-long"), Some(WayRealm::Urban));
2858 Ok(())
2859 }
2860
2861 #[test]
2862 fn nearest_street_bridge_may_close_a_trail_loop() -> Result<()> {
2863 let raw = r#"<osm version="0.6">
2864 <node id="1" lon="0.000" lat="0.000"/><node id="2" lon="0.001" lat="0.001"/>
2865 <node id="3" lon="0.002" lat="0.000"/>
2866 <way id="trail"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="path"/></way>
2867 <way id="street-closure"><nd ref="1"/><nd ref="3"/><tag k="highway" v="service"/></way>
2868 </osm>"#;
2869
2870 let drafts = classify_osm_realms(osm::network_from_str(raw)?);
2871
2872 assert!(drafts.iter().any(|draft| {
2873 draft
2874 .provenance
2875 .iter()
2876 .any(|provenance| provenance.source_id.as_deref() == Some("street-closure"))
2877 }));
2878 Ok(())
2879 }
2880
2881 #[test]
2882 fn line_clipping_realizes_the_exact_rectangle_union() -> Result<()> {
2883 let line = LineString::new(vec![Coord::new(-2.0, 0.0), Coord::new(2.0, 0.0)])?;
2884 let regions = [
2885 SurveyRegion::new(GeoBounds::new(-1.0, -0.5, -0.2, 0.5))?,
2886 SurveyRegion::new(GeoBounds::new(0.3, -0.5, 1.0, 0.5))?,
2887 ];
2888
2889 let clipped = clip_line(&line, ®ions);
2890
2891 assert_eq!(clipped.len(), 2);
2892 for (actual, expected) in [
2893 (clipped[0].start(), Coord::new(-1.0, 0.0)),
2894 (clipped[0].end(), Coord::new(-0.2, 0.0)),
2895 (clipped[1].start(), Coord::new(0.3, 0.0)),
2896 (clipped[1].end(), Coord::new(1.0, 0.0)),
2897 ] {
2898 assert!((actual.lon - expected.lon).abs() < 1.0e-12);
2899 assert!((actual.lat - expected.lat).abs() < 1.0e-12);
2900 }
2901 Ok(())
2902 }
2903
2904 #[test]
2905 fn clipping_preserves_contracted_osm_junctions() -> Result<()> {
2906 let raw = r#"<osm version="0.6">
2907 <node id="1" lon="-1" lat="0"/><node id="2" lon="0" lat="0"/>
2908 <node id="3" lon="1" lat="0"/><node id="4" lon="0" lat="1"/>
2909 <way id="10"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="path"/></way>
2910 <way id="11"><nd ref="2"/><nd ref="4"/><tag k="highway" v="path"/></way>
2911 </osm>"#;
2912 let region = SurveyRegion::new(GeoBounds::new(-0.5, -0.5, 0.5, 0.5))?;
2913
2914 let drafts = clip_drafts(osm::network_from_str(raw)?, &[region]);
2915 assert!(
2916 drafts
2917 .iter()
2918 .all(|draft| draft.junctions == JunctionPolicy::ExplicitEndpoints)
2919 );
2920 let graph = GraphBuilder::default().build(&drafts)?;
2921 let junction = graph
2922 .vertices
2923 .iter()
2924 .find(|vertex| same_location(vertex.coord, Coord::new(0.0, 0.0)))
2925 .context("shared OSM node should survive clipping")?;
2926 assert_eq!(graph.adjacency[junction.id.0].len(), 3);
2927 Ok(())
2928 }
2929
2930 #[test]
2931 fn clipping_does_not_join_distinct_osm_ways_at_a_shared_boundary_coordinate() -> Result<()> {
2932 let raw = r#"<osm version="0.6">
2933 <node id="1" lon="-1" lat="0"/><node id="2" lon="0" lat="0"/>
2934 <node id="3" lon="-1" lat="0"/><node id="4" lon="0.5" lat="0"/>
2935 <way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="path"/></way>
2936 <way id="11"><nd ref="3"/><nd ref="4"/><tag k="highway" v="path"/></way>
2937 </osm>"#;
2938 let region = SurveyRegion::new(GeoBounds::new(-0.5, -0.5, 0.5, 0.5))?;
2939
2940 let drafts = clip_drafts(osm::network_from_str(raw)?, &[region]);
2941 let graph = GraphBuilder::default().build(&drafts)?;
2942
2943 assert_eq!(graph.edges.len(), 2);
2944 assert_eq!(
2945 graph
2946 .vertices
2947 .iter()
2948 .filter(|vertex| same_location(vertex.coord, Coord::new(-0.5, 0.0)))
2949 .count(),
2950 2
2951 );
2952 Ok(())
2953 }
2954
2955 #[test]
2956 fn project_area_update_preserves_the_project_name() -> Result<()> {
2957 let temp = tempfile::tempdir()?;
2958 fs::write(temp.path().join("trailgen.toml"), "name = 'Harriman'\n")?;
2959 let bounds = GeoBounds::new(-74.2, 41.1, -74.0, 41.35);
2960 store_area(temp.path(), Some(bounds))?;
2961 let config = fs::read_to_string(temp.path().join("trailgen.toml"))?;
2962 assert!(config.contains("name = \"Harriman\""));
2963 assert!(config.contains("[area]"));
2964 Ok(())
2965 }
2966
2967 #[test]
2968 fn legacy_place_demand_migrates_to_its_recorded_rectangle() -> Result<()> {
2969 let temp = tempfile::tempdir()?;
2970 fs::write(
2971 temp.path().join("trailgen.toml"),
2972 "name = 'Harriman'\n[trail_data]\nplace = 'Harriman, NY'\nradius_km = 8\n\
2973 [area]\nwest = -74.2\nsouth = 41.1\neast = -74.0\nnorth = 41.35\n",
2974 )?;
2975
2976 let config = project_config(temp.path())?;
2977
2978 assert_eq!(config.regions.len(), 1);
2979 assert_eq!(
2980 config.regions[0].bounds,
2981 GeoBounds::new(-74.2, 41.1, -74.0, 41.35)
2982 );
2983 Ok(())
2984 }
2985
2986 #[test]
2987 fn survey_sequesters_indexes_and_reuses_one_canonical_pipeline() -> Result<()> {
2988 let temp = tempfile::tempdir()?;
2989 let project = temp.path();
2990 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2991 let (surveyor, calls) = fixed_surveyor();
2992 let mut first_events = Vec::new();
2993
2994 let first = surveyor.survey(project, "Harriman", 20.0, |event| {
2995 first_events.push(event.status());
2996 })?;
2997
2998 assert!(!first.reused);
2999 assert_eq!(first.inventory.trail_segments, 2);
3000 assert_eq!(first.inventory.road_features, 1);
3001 assert_eq!(first.inventory.waterway_features, 1);
3002 assert_eq!(calls.get(), 1);
3003 assert_eq!(first_events.len(), 6);
3004 assert_eq!(first.regions.len(), 1);
3005 assert_eq!(first.providers, vec![ProviderId::new("fixture")?]);
3006 assert_eq!(first.raw_paths.len(), 1);
3007 assert!(project.join(&first.raw_paths[0]).is_file());
3008 assert_eq!(indexed_summary(project)?, Some(first.clone()));
3009 assert!(
3010 project
3011 .join(&first.raw_paths[0])
3012 .with_extension("json")
3013 .is_file()
3014 );
3015 assert!(
3016 project
3017 .join(&first.raw_paths[0])
3018 .with_extension("request")
3019 .is_file()
3020 );
3021 for artifact in [LOCATION_CACHE, TRAIL_INDEX, GRAPH_CACHE, SOURCE_MANIFEST] {
3022 assert!(project.join(artifact).is_file(), "missing {artifact}");
3023 }
3024 for absent in [GRAPH_GEOJSON, "cache/edges.csv", "cache/vertices.csv"] {
3025 assert!(!project.join(absent).exists(), "unsolicited {absent}");
3026 }
3027 let manifest = serde_json::from_str::<SourceManifest>(&fs::read_to_string(
3028 project.join(SOURCE_MANIFEST),
3029 )?)?;
3030 assert_eq!(manifest.candidates.len(), 1);
3031 assert!(manifest.candidates.iter().all(|candidate| {
3032 candidate
3033 .origin
3034 .as_deref()
3035 .is_some_and(|origin| origin.contains("fixture://overpass"))
3036 }));
3037
3038 let mut second_events = Vec::new();
3039 let second = surveyor.survey(project, " harriman ", 20.0, |event| {
3040 second_events.push(event.status());
3041 })?;
3042 assert!(second.reused);
3043 assert_eq!(second.raw_paths, first.raw_paths);
3044 assert_eq!(calls.get(), 1);
3045 assert_eq!(second_events.len(), 3);
3046 assert!(
3047 second_events
3048 .last()
3049 .is_some_and(|event| event.contains("CACHED"))
3050 );
3051 Ok(())
3052 }
3053
3054 #[test]
3055 fn providers_keep_independent_receipts_but_feed_one_graph() -> Result<()> {
3056 let temp = tempfile::tempdir()?;
3057 let project = temp.path();
3058 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
3059 let (surveyor, osm_calls, usgs_calls) = fixed_multi_surveyor();
3060
3061 let first = surveyor.survey(project, "Harriman", 20.0, drop)?;
3062
3063 assert_eq!(osm_calls.get(), 1);
3064 assert_eq!(usgs_calls.get(), 1);
3065 assert_eq!(first.providers.len(), 2);
3066 assert_eq!(first.raw_paths.len(), 2);
3067 assert_eq!(first.conflation.strata, 2);
3068 let graph = decode_graph(&fs::read(project.join(GRAPH_CACHE))?)?;
3069 let sources = graph
3070 .edges
3071 .iter()
3072 .flat_map(|edge| &edge.attr.provenance)
3073 .map(|provenance| provenance.source.as_str())
3074 .collect::<BTreeSet<_>>();
3075 assert!(sources.contains("osm-xml"));
3076 assert!(sources.contains("usgs-national-trails"));
3077
3078 let cached = surveyor.survey(project, "Harriman", 20.0, drop)?;
3079 assert!(cached.reused);
3080 assert_eq!(osm_calls.get(), 1);
3081 assert_eq!(usgs_calls.get(), 1);
3082
3083 let usgs_raw = first
3084 .raw_paths
3085 .iter()
3086 .find(|path| path.starts_with("sources/usgs-national-trails"))
3087 .context("USGS receipt missing")?;
3088 fs::write(project.join(usgs_raw), b"damaged")?;
3089 assert_eq!(indexed_summary(project)?, Some(first));
3090 let repaired = surveyor.survey(project, "Harriman", 20.0, drop)?;
3091 assert!(!repaired.reused);
3092 assert_eq!(osm_calls.get(), 1);
3093 assert_eq!(usgs_calls.get(), 2);
3094 Ok(())
3095 }
3096
3097 #[test]
3098 fn a_drifted_graph_is_rebuilt_from_the_sequestered_source() -> Result<()> {
3099 let temp = tempfile::tempdir()?;
3100 let project = temp.path();
3101 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
3102 let (surveyor, calls) = fixed_surveyor();
3103 surveyor.survey(project, "Harriman", 20.0, drop)?;
3104 fs::write(project.join(GRAPH_CACHE), b"drift")?;
3105
3106 assert!(indexed_summary(project)?.is_none());
3107
3108 let repaired = surveyor.survey(project, "Harriman", 20.0, drop)?;
3109
3110 assert!(!repaired.reused);
3111 assert_eq!(calls.get(), 1);
3112 decode_graph(&fs::read(project.join(GRAPH_CACHE))?)?;
3113 Ok(())
3114 }
3115
3116 #[test]
3117 fn damaged_derived_receipts_are_repaired_without_poisoning_the_project() -> Result<()> {
3118 let temp = tempfile::tempdir()?;
3119 let project = temp.path();
3120 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
3121 let (surveyor, calls) = fixed_surveyor();
3122 let first = surveyor.survey(project, "Harriman", 20.0, drop)?;
3123 fs::write(project.join(TRAIL_INDEX), b"{")?;
3124
3125 let repaired = surveyor.survey(project, "Harriman", 20.0, drop)?;
3126
3127 assert!(!repaired.reused);
3128 assert_eq!(calls.get(), 1);
3129 let artifact = project.join(&first.raw_paths[0]).with_extension("json");
3130 fs::write(artifact, b"{")?;
3131 fs::write(project.join(TRAIL_INDEX), b"{")?;
3132
3133 surveyor.survey(project, "Harriman", 20.0, drop)?;
3134
3135 assert_eq!(calls.get(), 2);
3136 Ok(())
3137 }
3138
3139 #[test]
3140 fn overlapping_regions_form_one_deduplicated_corpus_and_can_be_excised() -> Result<()> {
3141 let temp = tempfile::tempdir()?;
3142 let project = temp.path();
3143 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
3144 let (surveyor, calls) = fixed_surveyor();
3145 let west = GeoBounds::new(-74.130, 41.225, -74.120, 41.235);
3146 let east = GeoBounds::new(-74.127, 41.228, -74.123, 41.234);
3147
3148 let first = surveyor.add_region(project, west, drop)?;
3149 let joined = surveyor.add_region(project, east, drop)?;
3150
3151 assert_eq!(calls.get(), 2);
3152 assert_eq!(joined.regions.len(), 2);
3153 assert_eq!(joined.inventory, first.inventory);
3154 assert_eq!(joined.vertices, first.vertices);
3155 assert_eq!(joined.edges, first.edges);
3156 assert_eq!(project_config(project)?.regions, joined.regions);
3157
3158 let shorn = surveyor
3159 .remove_region(project, &joined.regions[1].id, drop)?
3160 .context("one region should survive")?;
3161 assert_eq!(shorn.regions, first.regions);
3162 assert_eq!(calls.get(), 2);
3163 let removed = joined
3164 .raw_paths
3165 .iter()
3166 .find(|path| path.to_string_lossy().contains(&joined.regions[1].id))
3167 .context("second region receipt missing")?;
3168 assert!(!project.join(removed).exists());
3169 assert!(
3170 surveyor
3171 .remove_region(project, &first.regions[0].id, drop)?
3172 .is_none()
3173 );
3174 assert!(project_config(project)?.regions.is_empty());
3175 assert!(project_config(project)?.managed);
3176 assert!(!project.join(GRAPH_CACHE).exists());
3177 assert!(!project.join(&first.raw_paths[0]).exists());
3178 Ok(())
3179 }
3180
3181 #[test]
3182 fn replacing_a_region_preserves_its_slot_name_and_project_law() -> Result<()> {
3183 let temp = tempfile::tempdir()?;
3184 let project = temp.path();
3185 fs::write(
3186 project.join("trailgen.toml"),
3187 "name = 'Harriman'\nproject_marker = 'untouched'\n",
3188 )?;
3189 let (surveyor, calls) = fixed_surveyor();
3190 let west = GeoBounds::new(-74.130, 41.225, -74.120, 41.235);
3191 let east = GeoBounds::new(-74.127, 41.228, -74.123, 41.234);
3192 let moved = GeoBounds::new(-74.131, 41.224, -74.119, 41.236);
3193 let first = surveyor.add_region(project, west, drop)?;
3194 let joined = surveyor.add_region(project, east, drop)?;
3195 let displaced = first.regions[0].id.clone();
3196 let survivor = joined.regions[1].clone();
3197 name_region(project, &displaced, "West Gate")?;
3198
3199 let replaced = surveyor.replace_region(project, &displaced, moved, drop)?;
3200 let replacement = SurveyRegion::new(moved)?;
3201 let config = project_config(project)?;
3202
3203 assert_eq!(calls.get(), 3);
3204 assert_eq!(replaced.regions, vec![replacement.clone(), survivor]);
3205 assert_eq!(config.regions, replaced.regions);
3206 assert!(config.managed);
3207 assert_eq!(config.providers, vec![ProviderId::new("fixture")?]);
3208 assert_eq!(
3209 config.region_names.get(&replacement.id).map(String::as_str),
3210 Some("West Gate")
3211 );
3212 assert!(!config.region_names.contains_key(&displaced));
3213 assert_eq!(indexed_summary(project)?, Some(replaced));
3214 let document = fs::read_to_string(project.join("trailgen.toml"))?;
3215 let document = toml::from_str::<toml::Value>(&document)?;
3216 assert_eq!(
3217 document.get("name").and_then(toml::Value::as_str),
3218 Some("Harriman")
3219 );
3220 assert_eq!(
3221 document.get("project_marker").and_then(toml::Value::as_str),
3222 Some("untouched")
3223 );
3224 Ok(())
3225 }
3226
3227 #[test]
3228 fn replacement_intent_survives_an_interrupted_acquisition() -> Result<()> {
3229 let temp = tempfile::tempdir()?;
3230 let project = temp.path();
3231 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
3232 let provider = FixedProvider {
3233 fail_at: Some(3),
3234 ..FixedProvider::default()
3235 };
3236 let calls = Rc::clone(&provider.calls);
3237 let surveyor = Surveyor::new(FixedPlace, provider);
3238 let west = GeoBounds::new(-74.130, 41.225, -74.120, 41.235);
3239 let east = GeoBounds::new(-74.127, 41.228, -74.123, 41.234);
3240 let moved = GeoBounds::new(-74.131, 41.224, -74.119, 41.236);
3241 let first = surveyor.add_region(project, west, drop)?;
3242 let joined = surveyor.add_region(project, east, drop)?;
3243 let displaced = first.regions[0].id.clone();
3244 let survivor = joined.regions[1].clone();
3245 name_region(project, &displaced, "West Gate")?;
3246
3247 let fault = surveyor
3248 .replace_region(project, &displaced, moved, drop)
3249 .expect_err("the fixture's third acquisition must fail");
3250 let replacement = SurveyRegion::new(moved)?;
3251 let config = project_config(project)?;
3252
3253 assert!(fault.to_string().contains("fixture acquisition failure"));
3254 assert_eq!(calls.get(), 3);
3255 assert_eq!(config.regions, vec![replacement.clone(), survivor]);
3256 assert_eq!(
3257 config.region_names,
3258 BTreeMap::from([(replacement.id, "West Gate".to_owned())])
3259 );
3260 assert!(indexed_summary(project)?.is_none());
3261 Ok(())
3262 }
3263}