Skip to main content

trailgen_data/
lib.rs

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