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}