Skip to main content

trailgen_core/
source.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3use std::path::Path;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "kebab-case")]
7pub enum SourceKind {
8    TrailNetwork,
9    SeedRoute,
10    Elevation,
11    Terrain,
12    Access,
13    Closure,
14    Road,
15    Hydrology,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
19pub struct GeoBounds {
20    pub west: f64,
21    pub south: f64,
22    pub east: f64,
23    pub north: f64,
24}
25
26impl GeoBounds {
27    #[must_use]
28    pub const fn new(west: f64, south: f64, east: f64, north: f64) -> Self {
29        Self {
30            west,
31            south,
32            east,
33            north,
34        }
35    }
36
37    #[must_use]
38    pub fn is_valid(self) -> bool {
39        self.west >= -180.0
40            && self.east <= 180.0
41            && self.south >= -90.0
42            && self.north <= 90.0
43            && self.west < self.east
44            && self.south < self.north
45    }
46}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum SourcePriority {
51    Required,
52    Recommended,
53    Optional,
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum SourceCoverageStatus {
59    Satisfied,
60    Missing,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
64pub struct SourceAdapter {
65    pub id: String,
66    pub kind: SourceKind,
67    pub consumes: Vec<String>,
68    pub produces: Vec<String>,
69    pub note: String,
70}
71
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct SourceRecommendation {
74    pub kind: SourceKind,
75    pub priority: SourcePriority,
76    pub adapter_ids: Vec<String>,
77    pub suggested_paths: Vec<String>,
78    #[serde(default)]
79    pub acquisition_hints: Vec<AcquisitionHint>,
80    pub search_terms: Vec<String>,
81    pub acceptance: String,
82    pub rationale: String,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub area: Option<GeoBounds>,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
88pub struct AcquisitionHint {
89    pub label: String,
90    pub url: String,
91    pub formats: Vec<String>,
92    pub note: String,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct SourceCandidate {
97    pub path: String,
98    pub kind: SourceKind,
99    pub adapter_id: String,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub origin: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub fingerprint: Option<SourceFingerprint>,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
107pub struct SourceFingerprint {
108    pub bytes: u64,
109    pub sha256: String,
110}
111
112#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
113pub struct SourceCoverage {
114    pub kind: SourceKind,
115    pub priority: SourcePriority,
116    pub status: SourceCoverageStatus,
117    pub candidate_paths: Vec<String>,
118    pub implemented_adapter_ids: Vec<String>,
119    pub message: String,
120}
121
122#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
123pub struct SourceCoverageTally {
124    pub total: usize,
125    pub satisfied: usize,
126    pub missing: usize,
127}
128
129#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
130pub struct SourceCoverageSummary {
131    pub total: SourceCoverageTally,
132    pub required: SourceCoverageTally,
133    pub recommended: SourceCoverageTally,
134    pub optional: SourceCoverageTally,
135    pub missing_required: Vec<SourceKind>,
136    pub missing_recommended: Vec<SourceKind>,
137}
138
139impl SourceCoverageTally {
140    const fn record(&mut self, status: SourceCoverageStatus) {
141        self.total += 1;
142        match status {
143            SourceCoverageStatus::Satisfied => self.satisfied += 1,
144            SourceCoverageStatus::Missing => self.missing += 1,
145        }
146    }
147}
148
149impl SourceCoverageSummary {
150    #[must_use]
151    pub const fn required_complete(&self) -> bool {
152        self.required.missing == 0
153    }
154
155    #[must_use]
156    pub const fn recommended_complete(&self) -> bool {
157        self.recommended.missing == 0
158    }
159}
160
161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
162pub struct SourceManifest {
163    pub adapters: Vec<SourceAdapter>,
164    #[serde(default)]
165    pub recommendations: Vec<SourceRecommendation>,
166    #[serde(default)]
167    pub coverage: Vec<SourceCoverage>,
168    pub candidates: Vec<SourceCandidate>,
169}
170
171#[must_use]
172pub fn summarize_source_coverage(coverage: &[SourceCoverage]) -> SourceCoverageSummary {
173    let mut summary = SourceCoverageSummary::default();
174    for entry in coverage {
175        summary.total.record(entry.status);
176        match entry.priority {
177            SourcePriority::Required => {
178                summary.required.record(entry.status);
179                record_gap(entry, &mut summary.missing_required);
180            }
181            SourcePriority::Recommended => {
182                summary.recommended.record(entry.status);
183                record_gap(entry, &mut summary.missing_recommended);
184            }
185            SourcePriority::Optional => summary.optional.record(entry.status),
186        }
187    }
188    summary.missing_required.sort();
189    summary.missing_recommended.sort();
190    summary
191}
192
193fn record_gap(entry: &SourceCoverage, missing: &mut Vec<SourceKind>) {
194    if entry.status == SourceCoverageStatus::Missing {
195        missing.push(entry.kind);
196    }
197}
198
199#[must_use]
200pub fn source_coverage(
201    adapters: &[SourceAdapter],
202    recommendations: &[SourceRecommendation],
203    candidates: &[SourceCandidate],
204) -> Vec<SourceCoverage> {
205    let adapter_ids = adapters
206        .iter()
207        .map(|adapter| adapter.id.as_str())
208        .collect::<BTreeSet<_>>();
209    recommendations
210        .iter()
211        .map(|recommendation| coverage_for_recommendation(recommendation, candidates, &adapter_ids))
212        .collect()
213}
214
215fn coverage_for_recommendation(
216    recommendation: &SourceRecommendation,
217    candidates: &[SourceCandidate],
218    adapter_ids: &BTreeSet<&str>,
219) -> SourceCoverage {
220    let matching = candidates
221        .iter()
222        .filter(|candidate| {
223            candidate.kind == recommendation.kind
224                && recommendation
225                    .adapter_ids
226                    .iter()
227                    .any(|id| id == &candidate.adapter_id)
228        })
229        .collect::<Vec<_>>();
230    let candidate_paths = matching
231        .iter()
232        .map(|candidate| candidate.path.clone())
233        .collect::<Vec<_>>();
234    let implemented_adapter_ids = matching
235        .iter()
236        .filter(|candidate| adapter_ids.contains(candidate.adapter_id.as_str()))
237        .map(|candidate| candidate.adapter_id.clone())
238        .collect::<BTreeSet<_>>()
239        .into_iter()
240        .collect::<Vec<_>>();
241    let status = if implemented_adapter_ids.is_empty() {
242        SourceCoverageStatus::Missing
243    } else {
244        SourceCoverageStatus::Satisfied
245    };
246    SourceCoverage {
247        kind: recommendation.kind,
248        priority: recommendation.priority,
249        status,
250        candidate_paths,
251        implemented_adapter_ids,
252        message: coverage_message(recommendation, status),
253    }
254}
255
256fn coverage_message(recommendation: &SourceRecommendation, status: SourceCoverageStatus) -> String {
257    match status {
258        SourceCoverageStatus::Satisfied => {
259            format!(
260                "{:?} source requirement has implemented candidate(s).",
261                recommendation.kind
262            )
263        }
264        SourceCoverageStatus::Missing => format!(
265            "{:?} source is {:?}; acquire one of {}.",
266            recommendation.kind,
267            recommendation.priority,
268            recommendation.suggested_paths.join(", ")
269        ),
270    }
271}
272
273#[must_use]
274pub fn adapter_registry() -> Vec<SourceAdapter> {
275    let mut adapters = network_adapters();
276    adapters.extend(route_adapters());
277    adapters.extend(elevation_adapters());
278    adapters.extend(overlay_context_adapters());
279    adapters
280}
281
282fn network_adapters() -> Vec<SourceAdapter> {
283    vec![
284        adapter(
285            "geojson-network",
286            SourceKind::TrailNetwork,
287            ["geojson", "json"],
288            ["SegmentDraft", "WalkGraph"],
289            "Provider-neutral LineString and MultiLineString network ingestion.",
290        ),
291        adapter(
292            "shapefile-network",
293            SourceKind::TrailNetwork,
294            ["shp", "dbf", "shx"],
295            ["SegmentDraft", "WalkGraph"],
296            "Official/agency polyline shapefile trail-network ingestion with DBF attribute normalization.",
297        ),
298        adapter(
299            "osm-xml-network",
300            SourceKind::TrailNetwork,
301            ["osm"],
302            ["SegmentDraft", "WalkGraph"],
303            "OSM XML walkable-way trail-network ingestion with access, surface, direction, and provenance normalization.",
304        ),
305        adapter(
306            "osm-pbf-network",
307            SourceKind::TrailNetwork,
308            ["osm.pbf"],
309            ["SegmentDraft", "WalkGraph"],
310            "OSM PBF extract ingestion for walkable ways with access, surface, direction, and ODbL provenance normalization.",
311        ),
312    ]
313}
314
315fn route_adapters() -> Vec<SourceAdapter> {
316    vec![
317        adapter(
318            "geojson-route",
319            SourceKind::SeedRoute,
320            ["geojson"],
321            ["LineString", "snapped route metrics"],
322            "GeoJSON seed route import.",
323        ),
324        adapter(
325            "json-route",
326            SourceKind::SeedRoute,
327            ["json"],
328            ["LineString", "snapped route metrics"],
329            "Provider-neutral route JSON import for coordinate arrays and point-object app exports.",
330        ),
331        adapter(
332            "gpx-route",
333            SourceKind::SeedRoute,
334            ["gpx"],
335            ["LineString", "snapped route metrics"],
336            "GPX route import/export, including user-supplied app exports.",
337        ),
338        adapter(
339            "csv-route",
340            SourceKind::SeedRoute,
341            ["csv"],
342            ["LineString", "snapped route metrics"],
343            "CSV lon/lat/elevation route import/export for manual app exchange.",
344        ),
345        adapter(
346            "kml-route",
347            SourceKind::SeedRoute,
348            ["kml", "kmz"],
349            ["LineString", "snapped route metrics"],
350            "KML/KMZ route import/export for manual map-app exchange.",
351        ),
352    ]
353}
354
355fn elevation_adapters() -> Vec<SourceAdapter> {
356    vec![
357        adapter(
358            "arc-ascii-elevation",
359            SourceKind::Elevation,
360            ["asc"],
361            ["sampled elevation profile", "edge ascent/descent"],
362            "Arc/Info ASCII Grid DEM sampling for local elevation enrichment.",
363        ),
364        adapter(
365            "geotiff-elevation",
366            SourceKind::Elevation,
367            ["tif", "tiff"],
368            ["sampled elevation profile", "edge ascent/descent"],
369            "Affine WGS84/NAD83, EPSG:3857, or WGS84/NAD83 UTM single-band GeoTIFF DEM sampling.",
370        ),
371        adapter(
372            "vrt-elevation",
373            SourceKind::Elevation,
374            ["vrt"],
375            ["sampled elevation profile", "edge ascent/descent"],
376            "GDAL VRT SimpleSource DEM wrapper with affine WGS84/NAD83, EPSG:3857, or WGS84/NAD83 UTM GeoTransform sampling.",
377        ),
378    ]
379}
380
381fn overlay_context_adapters() -> Vec<SourceAdapter> {
382    vec![
383        adapter(
384            "geojson-terrain-overlay",
385            SourceKind::Terrain,
386            ["geojson", "json"],
387            ["terrain overrides", "confidence/provenance"],
388            "GeoJSON land-cover, surface, or user terrain overlays applied after graph construction.",
389        ),
390        adapter(
391            "shapefile-terrain-overlay",
392            SourceKind::Terrain,
393            ["shp", "dbf", "shx"],
394            ["terrain overrides", "confidence/provenance"],
395            "Polygon or line shapefile land-cover, surface, or terrain overlays applied after graph construction.",
396        ),
397        adapter(
398            "geojson-access-overlay",
399            SourceKind::Access,
400            ["geojson", "json"],
401            ["access overrides", "confidence/provenance"],
402            "GeoJSON access/status overlay applied after graph construction.",
403        ),
404        adapter(
405            "shapefile-access-overlay",
406            SourceKind::Access,
407            ["shp", "dbf", "shx"],
408            ["access overrides", "confidence/provenance"],
409            "Polygon or line shapefile access/status overlay applied after graph construction.",
410        ),
411        adapter(
412            "geojson-closure-overlay",
413            SourceKind::Closure,
414            ["geojson", "json"],
415            ["access overrides", "confidence/provenance"],
416            "GeoJSON closure/restriction overlay applied after graph construction.",
417        ),
418        adapter(
419            "geojson-road-context",
420            SourceKind::Road,
421            ["geojson", "json"],
422            ["road crossings", "road exposure hints"],
423            "GeoJSON road/street context lines used to infer trail crossings.",
424        ),
425        adapter(
426            "shapefile-road-context",
427            SourceKind::Road,
428            ["shp", "dbf", "shx"],
429            ["road crossings", "road exposure hints"],
430            "Shapefile road/street centerlines used to infer trail crossings.",
431        ),
432        adapter(
433            "osm-road-context",
434            SourceKind::Road,
435            ["osm", "osm.pbf"],
436            ["road crossings", "road exposure hints"],
437            "OSM XML/PBF highway centerlines used to infer trail crossings and road exposure.",
438        ),
439        adapter(
440            "geojson-hydrology-context",
441            SourceKind::Hydrology,
442            ["geojson", "json"],
443            ["water crossings"],
444            "GeoJSON stream/river context lines used to infer water crossings.",
445        ),
446        adapter(
447            "shapefile-hydrology-context",
448            SourceKind::Hydrology,
449            ["shp", "dbf", "shx"],
450            ["water crossings"],
451            "Shapefile stream/river centerlines used to infer water crossings.",
452        ),
453        adapter(
454            "osm-hydrology-context",
455            SourceKind::Hydrology,
456            ["osm", "osm.pbf"],
457            ["water crossings"],
458            "OSM XML/PBF waterway linework used to infer stream, river, canal, drain, and ditch crossings.",
459        ),
460        adapter(
461            "shapefile-closure-layer",
462            SourceKind::Closure,
463            ["shp", "dbf", "shx"],
464            ["access overrides", "confidence/provenance"],
465            "Official park/agency shapefile closure and restriction overlays.",
466        ),
467    ]
468}
469
470fn adapter<const C: usize, const P: usize>(
471    id: &str,
472    kind: SourceKind,
473    consumes: [&str; C],
474    produces: [&str; P],
475    note: &str,
476) -> SourceAdapter {
477    SourceAdapter {
478        id: id.to_owned(),
479        kind,
480        consumes: consumes.into_iter().map(str::to_owned).collect(),
481        produces: produces.into_iter().map(str::to_owned).collect(),
482        note: note.to_owned(),
483    }
484}
485
486#[must_use]
487pub fn discovery_recommendations(area: Option<GeoBounds>) -> Vec<SourceRecommendation> {
488    RECOMMENDATION_SPECS
489        .iter()
490        .map(|spec| spec.materialize(area))
491        .collect()
492}
493
494struct RecommendationSpec {
495    kind: SourceKind,
496    priority: SourcePriority,
497    adapter_ids: &'static [&'static str],
498    suggested_paths: &'static [&'static str],
499    acquisition_hints: &'static [AcquisitionHintSpec],
500    search_terms: &'static [&'static str],
501    acceptance: &'static str,
502    rationale: &'static str,
503}
504
505struct AcquisitionHintSpec {
506    label: &'static str,
507    url: &'static str,
508    formats: &'static [&'static str],
509    note: &'static str,
510}
511
512impl RecommendationSpec {
513    fn materialize(&self, area: Option<GeoBounds>) -> SourceRecommendation {
514        SourceRecommendation {
515            kind: self.kind,
516            priority: self.priority,
517            adapter_ids: strings(self.adapter_ids),
518            suggested_paths: strings(self.suggested_paths),
519            acquisition_hints: self
520                .acquisition_hints
521                .iter()
522                .map(AcquisitionHintSpec::materialize)
523                .collect(),
524            search_terms: strings(self.search_terms),
525            acceptance: self.acceptance.to_owned(),
526            rationale: self.rationale.to_owned(),
527            area,
528        }
529    }
530}
531
532impl AcquisitionHintSpec {
533    fn materialize(&self) -> AcquisitionHint {
534        AcquisitionHint {
535            label: self.label.to_owned(),
536            url: self.url.to_owned(),
537            formats: strings(self.formats),
538            note: self.note.to_owned(),
539        }
540    }
541}
542
543fn strings(xs: &[&str]) -> Vec<String> {
544    xs.iter().map(|x| (*x).to_owned()).collect()
545}
546
547const TRAIL_NETWORK_HINTS: &[AcquisitionHintSpec] = &[
548    AcquisitionHintSpec {
549        label: "NPS official GIS open data",
550        url: "https://www.nps.gov/subjects/gisandmapping/tools-and-data.htm",
551        formats: &["GeoJSON", "Shapefile", "Feature Service"],
552        note: "Use first for National Park Service units; cache exported trail linework under sources/ with provenance intact.",
553    },
554    AcquisitionHintSpec {
555        label: "USFS geospatial data discovery",
556        url: "https://data-usfs.hub.arcgis.com/",
557        formats: &["Shapefile", "File Geodatabase", "Feature Service"],
558        note: "Use for National Forest roads/trails and agency-managed transportation layers before falling back to volunteered data.",
559    },
560    AcquisitionHintSpec {
561        label: "Geofabrik OpenStreetMap extracts",
562        url: "https://download.geofabrik.de/",
563        formats: &["OSM PBF", "OSM XML after conversion", "Shapefile"],
564        note: "Use as a broad fallback extract, then filter hiking paths/tracks directly from OSM PBF or cache a normalized OSM XML/GeoJSON/shapefile artifact.",
565    },
566];
567
568const ELEVATION_HINTS: &[AcquisitionHintSpec] = &[
569    AcquisitionHintSpec {
570        label: "USGS The National Map Downloader",
571        url: "https://www.usgs.gov/tools/download-data-maps-national-map",
572        formats: &["GeoTIFF", "IMG"],
573        note: "Use 3DEP DEM products for United States AOIs; prefer GeoTIFF tiles that cover the graph envelope.",
574    },
575    AcquisitionHintSpec {
576        label: "USGS TNMAccess API",
577        url: "https://apps.nationalmap.gov/tnmaccess/",
578        formats: &["GeoTIFF", "JSON metadata"],
579        note: "Use for scripted 3DEP product discovery by bounding box before caching selected raster downloads.",
580    },
581];
582
583const TERRAIN_HINTS: &[AcquisitionHintSpec] = &[
584    AcquisitionHintSpec {
585        label: "MRLC NLCD data",
586        url: "https://www.mrlc.gov/data",
587        formats: &["GeoTIFF", "Raster service"],
588        note: "Use for broad land-cover evidence, then convert relevant classes into terrain overlays with explicit confidence.",
589    },
590    AcquisitionHintSpec {
591        label: "Agency surface or land-cover GIS",
592        url: "https://data-usfs.hub.arcgis.com/",
593        formats: &["Shapefile", "GeoJSON", "Feature Service"],
594        note: "Prefer local agency surface, land-cover, or trail-condition attributes when available.",
595    },
596];
597
598const CLOSURE_HINTS: &[AcquisitionHintSpec] = &[
599    AcquisitionHintSpec {
600        label: "Agency closure and alert GIS",
601        url: "https://public-nps.opendata.arcgis.com/",
602        formats: &["GeoJSON", "Shapefile", "Feature Service"],
603        note: "Use current official closure/restriction features; preserve dates, weekdays, hours, direction rules, and alert provenance in cached overlays.",
604    },
605    AcquisitionHintSpec {
606        label: "Local park or forest alerts",
607        url: "https://www.nps.gov/subjects/gisandmapping/tools-and-data.htm",
608        formats: &["GeoJSON", "Shapefile", "Web page"],
609        note: "When no machine layer exists, hand-normalize official closure geometry into a small GeoJSON overlay.",
610    },
611];
612
613const ACCESS_HINTS: &[AcquisitionHintSpec] = &[
614    AcquisitionHintSpec {
615        label: "USGS PAD-US data download",
616        url: "https://www.usgs.gov/programs/gap-analysis-project/science/pad-us-data-download",
617        formats: &["File Geodatabase", "Shapefile"],
618        note: "Use protected-area ownership/manager data as access context; normalize to open/restricted/private where justified.",
619    },
620    AcquisitionHintSpec {
621        label: "PAD-US protected areas overview",
622        url: "https://www.usgs.gov/programs/gap-analysis-project/science/protected-areas",
623        formats: &["Metadata", "Download links"],
624        note: "Use to understand PAD-US scope before treating ownership as a route legality signal.",
625    },
626];
627
628const ROAD_HINTS: &[AcquisitionHintSpec] = &[
629    AcquisitionHintSpec {
630        label: "USFS roads data",
631        url: "https://data.fs.usda.gov/geodata/edw/datasets.php?dsetCategory=transportation",
632        formats: &["Shapefile", "File Geodatabase", "Map service"],
633        note: "Use for National Forest road exposure and crossings; cache centerlines as road context.",
634    },
635    AcquisitionHintSpec {
636        label: "The National Map transportation",
637        url: "https://apps.nationalmap.gov/tnmaccess/",
638        formats: &["Shapefile", "GeoPackage", "JSON metadata"],
639        note: "Use TNM transportation products when local road centerlines are absent.",
640    },
641    AcquisitionHintSpec {
642        label: "Geofabrik OpenStreetMap roads",
643        url: "https://download.geofabrik.de/",
644        formats: &["OSM PBF", "Shapefile"],
645        note: "Use as a fallback road/street extract, then filter and normalize to context linework.",
646    },
647];
648
649const HYDROLOGY_HINTS: &[AcquisitionHintSpec] = &[
650    AcquisitionHintSpec {
651        label: "USGS National Hydrography products",
652        url: "https://www.usgs.gov/national-hydrography/access-national-hydrography-products",
653        formats: &["Shapefile", "File Geodatabase"],
654        note: "Use NHD/3DHP stream and waterbody linework to infer water crossings.",
655    },
656    AcquisitionHintSpec {
657        label: "The National Map hydrography",
658        url: "https://apps.nationalmap.gov/tnmaccess/",
659        formats: &["Shapefile", "File Geodatabase", "JSON metadata"],
660        note: "Use TNMAccess to locate hydrography products by AOI before caching selected linework.",
661    },
662];
663
664const SEED_ROUTE_HINTS: &[AcquisitionHintSpec] = &[
665    AcquisitionHintSpec {
666        label: "AllTrails import/export support",
667        url: "https://support.alltrails.com/hc/en-us/sections/360006411352-Importing-and-exporting-files",
668        formats: &["GPX", "GeoJSON", "KML", "KMZ", "CSV"],
669        note: "Use user-supplied exports as seed routes only; never couple core graph semantics to private AllTrails APIs.",
670    },
671    AcquisitionHintSpec {
672        label: "Personal GPS archives",
673        url: "file://local-user-supplied-routes",
674        formats: &["GPX", "GeoJSON", "KML", "KMZ", "CSV"],
675        note: "Cache completed hikes under sources/seeds or import them directly so provenance and fingerprints are preserved.",
676    },
677];
678
679const RECOMMENDATION_SPECS: &[RecommendationSpec] = &[
680    RecommendationSpec {
681        kind: SourceKind::TrailNetwork,
682        priority: SourcePriority::Required,
683        adapter_ids: &[
684            "geojson-network",
685            "shapefile-network",
686            "osm-xml-network",
687            "osm-pbf-network",
688        ],
689        suggested_paths: &[
690            "sources/trails.geojson",
691            "sources/network.geojson",
692            "sources/trails.shp",
693            "sources/osm-trails.osm",
694            "sources/osm-trails.osm.pbf",
695        ],
696        acquisition_hints: TRAIL_NETWORK_HINTS,
697        search_terms: &[
698            "official trail GIS line layer",
699            "OSM hiking path extract",
700            "park trail network GeoJSON",
701        ],
702        acceptance: "LineString or MultiLineString trail geometries covering the AOI, with names, access/surface tags when available, and enough topology to build junctions.",
703        rationale: "The normalized graph cannot exist without a routable trail network.",
704    },
705    RecommendationSpec {
706        kind: SourceKind::Elevation,
707        priority: SourcePriority::Required,
708        adapter_ids: &["arc-ascii-elevation", "geotiff-elevation", "vrt-elevation"],
709        suggested_paths: &["sources/dem.asc", "sources/dem.tif"],
710        acquisition_hints: ELEVATION_HINTS,
711        search_terms: &[
712            "USGS 3DEP DEM",
713            "Copernicus DEM",
714            "local elevation raster for hiking area",
715        ],
716        acceptance: "DEM coverage intersects every trail edge; vertical units and CRS are documented before enrichment.",
717        rationale: "Long-day route quality depends on ascent, descent, grade, and sustained steepness.",
718    },
719    RecommendationSpec {
720        kind: SourceKind::Terrain,
721        priority: SourcePriority::Recommended,
722        adapter_ids: &["geojson-terrain-overlay", "shapefile-terrain-overlay"],
723        suggested_paths: &[
724            "sources/terrain.geojson",
725            "sources/landcover.geojson",
726            "sources/terrain.shp",
727        ],
728        acquisition_hints: TERRAIN_HINTS,
729        search_terms: &[
730            "land cover polygons",
731            "trail surface GIS layer",
732            "alpine talus scramble terrain map",
733        ],
734        acceptance: "Terrain or surface features can be normalized into known buckets and carry confidence/provenance.",
735        rationale: "Terrain multipliers are inspectable only when roughness evidence is explicit instead of magical.",
736    },
737    RecommendationSpec {
738        kind: SourceKind::Closure,
739        priority: SourcePriority::Recommended,
740        adapter_ids: &["geojson-closure-overlay", "shapefile-closure-layer"],
741        suggested_paths: &[
742            "sources/closures.geojson",
743            "sources/access.geojson",
744            "sources/closures.shp",
745        ],
746        acquisition_hints: CLOSURE_HINTS,
747        search_terms: &[
748            "official trail closure layer",
749            "park access restriction GIS",
750            "seasonal closure boundary GeoJSON",
751        ],
752        acceptance: "Closure, private, restricted, open, and directional statuses can be attached to graph edges with temporal provenance.",
753        rationale: "A beautiful generated loop is trash if it crosses a closed trail or forbidden parcel.",
754    },
755    RecommendationSpec {
756        kind: SourceKind::Access,
757        priority: SourcePriority::Recommended,
758        adapter_ids: &["geojson-access-overlay", "shapefile-access-overlay"],
759        suggested_paths: &[
760            "sources/access.geojson",
761            "sources/ownership.geojson",
762            "sources/access.shp",
763        ],
764        acquisition_hints: ACCESS_HINTS,
765        search_terms: &[
766            "public access boundary GeoJSON",
767            "land ownership parcel open space GIS",
768            "park access status trail layer",
769        ],
770        acceptance: "Open, restricted, private, or unknown access statuses can be attached to graph edges with provenance.",
771        rationale: "Access and ownership boundaries are distinct from temporary closures and should be visible in route legality diagnostics.",
772    },
773    RecommendationSpec {
774        kind: SourceKind::Road,
775        priority: SourcePriority::Recommended,
776        adapter_ids: &[
777            "geojson-road-context",
778            "shapefile-road-context",
779            "osm-road-context",
780        ],
781        suggested_paths: &[
782            "sources/roads.geojson",
783            "sources/context-roads.geojson",
784            "sources/roads.shp",
785            "sources/roads.osm.pbf",
786        ],
787        acquisition_hints: ROAD_HINTS,
788        search_terms: &[
789            "road centerline GeoJSON",
790            "street context lines",
791            "OSM road extract",
792        ],
793        acceptance: "Road context lines cover the AOI and can identify crossings or road-exposed trail segments.",
794        rationale: "Road exposure and road crossings are hard constraints for many hikes.",
795    },
796    RecommendationSpec {
797        kind: SourceKind::Hydrology,
798        priority: SourcePriority::Recommended,
799        adapter_ids: &[
800            "geojson-hydrology-context",
801            "shapefile-hydrology-context",
802            "osm-hydrology-context",
803        ],
804        suggested_paths: &[
805            "sources/hydrology.geojson",
806            "sources/streams.geojson",
807            "sources/hydrology.shp",
808            "sources/hydrology.osm.pbf",
809        ],
810        acquisition_hints: HYDROLOGY_HINTS,
811        search_terms: &[
812            "NHD stream lines",
813            "hydrology GeoJSON",
814            "river creek crossing layer",
815        ],
816        acceptance: "Hydrology linework intersects likely crossings and carries source confidence where known.",
817        rationale: "Water crossings are route diagnostics, risk signals, and useful report context.",
818    },
819    RecommendationSpec {
820        kind: SourceKind::SeedRoute,
821        priority: SourcePriority::Optional,
822        adapter_ids: &[
823            "gpx-route",
824            "geojson-route",
825            "json-route",
826            "csv-route",
827            "kml-route",
828        ],
829        suggested_paths: &[
830            "sources/seeds/completed.gpx",
831            "sources/seeds/completed.csv",
832            "sources/seeds/alltrails-export.gpx",
833            "sources/seeds/reference.geojson",
834            "sources/seeds/app-export.json",
835        ],
836        acquisition_hints: SEED_ROUTE_HINTS,
837        search_terms: &[
838            "personal completed hike GPX",
839            "AllTrails export GPX",
840            "app route JSON export",
841            "reference route KML",
842        ],
843        acceptance: "Seed routes snap to the current graph and their provenance is preserved.",
844        rationale: "Seeds improve confidence/popularity hints and provide validation loops without contaminating the provider-neutral model.",
845    },
846];
847
848#[must_use]
849pub fn classify_path(path: &Path) -> Option<SourceCandidate> {
850    let path_lc = path.display().to_string().to_ascii_lowercase();
851    let ext = if path_lc.ends_with(".osm.pbf") {
852        "osm.pbf".to_owned()
853    } else {
854        path.extension()?.to_str()?.to_ascii_lowercase()
855    };
856    let (kind, adapter_id) = match ext.as_str() {
857        "gpx" => (SourceKind::SeedRoute, "gpx-route"),
858        "csv" => (SourceKind::SeedRoute, "csv-route"),
859        "kml" | "kmz" => (SourceKind::SeedRoute, "kml-route"),
860        "json"
861            if path_lc.contains("route")
862                || path_lc.contains("track")
863                || path_lc.contains("seed")
864                || path_lc.contains("activity") =>
865        {
866            (SourceKind::SeedRoute, "json-route")
867        }
868        "geojson" | "json" if path_lc.contains("closure") => {
869            (SourceKind::Closure, "geojson-closure-overlay")
870        }
871        "geojson" | "json" if path_lc.contains("access") => {
872            (SourceKind::Access, "geojson-access-overlay")
873        }
874        "geojson" | "json"
875            if path_lc.contains("terrain")
876                || path_lc.contains("surface")
877                || path_lc.contains("landcover")
878                || path_lc.contains("land-cover")
879                || path_lc.contains("land_cover") =>
880        {
881            (SourceKind::Terrain, "geojson-terrain-overlay")
882        }
883        "geojson" | "json" if path_lc.contains("road") => {
884            (SourceKind::Road, "geojson-road-context")
885        }
886        "geojson" | "json"
887            if path_lc.contains("hydrology")
888                || path_lc.contains("water")
889                || path_lc.contains("stream") =>
890        {
891            (SourceKind::Hydrology, "geojson-hydrology-context")
892        }
893        "geojson" | "json" => (SourceKind::TrailNetwork, "geojson-network"),
894        "osm" | "osm.pbf"
895            if path_lc.contains("road")
896                || path_lc.contains("street")
897                || path_lc.contains("highway") =>
898        {
899            (SourceKind::Road, "osm-road-context")
900        }
901        "osm" | "osm.pbf"
902            if path_lc.contains("hydrology")
903                || path_lc.contains("water")
904                || path_lc.contains("stream")
905                || path_lc.contains("river")
906                || path_lc.contains("creek") =>
907        {
908            (SourceKind::Hydrology, "osm-hydrology-context")
909        }
910        "osm" => (SourceKind::TrailNetwork, "osm-xml-network"),
911        "osm.pbf" => (SourceKind::TrailNetwork, "osm-pbf-network"),
912        "asc" => (SourceKind::Elevation, "arc-ascii-elevation"),
913        "tif" | "tiff" => (SourceKind::Elevation, "geotiff-elevation"),
914        "vrt" => (SourceKind::Elevation, "vrt-elevation"),
915        "shp" if path_lc.contains("closure") => (SourceKind::Closure, "shapefile-closure-layer"),
916        "shp" if path_lc.contains("access") || path_lc.contains("ownership") => {
917            (SourceKind::Access, "shapefile-access-overlay")
918        }
919        "shp"
920            if path_lc.contains("terrain")
921                || path_lc.contains("surface")
922                || path_lc.contains("landcover")
923                || path_lc.contains("land-cover")
924                || path_lc.contains("land_cover") =>
925        {
926            (SourceKind::Terrain, "shapefile-terrain-overlay")
927        }
928        "shp" if path_lc.contains("road") || path_lc.contains("street") => {
929            (SourceKind::Road, "shapefile-road-context")
930        }
931        "shp"
932            if path_lc.contains("hydrology")
933                || path_lc.contains("water")
934                || path_lc.contains("stream") =>
935        {
936            (SourceKind::Hydrology, "shapefile-hydrology-context")
937        }
938        "shp" => (SourceKind::TrailNetwork, "shapefile-network"),
939        _ => return None,
940    };
941    Some(SourceCandidate {
942        path: path.display().to_string(),
943        kind,
944        adapter_id: adapter_id.to_owned(),
945        origin: None,
946        fingerprint: None,
947    })
948}