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