Skip to main content

par_osm_rust/
sources.rs

1//! Shared source orchestration for OSM/Overpass plus optional Overture Maps data.
2//!
3//! This module is the preferred entry point for applications that want a single
4//! fetch path with consistent source policy, POI dedupe, fallback warnings, and
5//! progress reporting. It always fetches OSM/Overpass data first. Overture is
6//! fetched only when [`SourceOptions::overture`] has `enabled = true`; source
7//! mode alone never forces an Overture network/CLI request.
8//!
9//! The pure merge function [`merge_source_data`] is separated from the
10//! side-effecting [`fetch_map_data`] entry point so tests and consumers can reuse
11//! the policy logic with already-loaded data.
12
13use std::collections::HashMap;
14
15use anyhow::Result;
16
17use crate::filter::FeatureFilter;
18use crate::osm::{FeatureSource, OsmData, OsmPoiNode};
19use crate::overture::OvertureParams;
20
21/// Policy for which POI source should appear in the normalized output.
22///
23/// Non-POI Overture geometry may still be merged according to Overture theme
24/// priority when Overture data is fetched. This enum only controls the final
25/// `OsmData::poi_nodes` collection.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum PoiSourceMode {
29    /// Use OSM POIs only.
30    OsmOnly,
31    /// Use Overture POIs only; OSM POIs are cleared when Overture is unavailable.
32    OvertureOnly,
33    /// Merge OSM and Overture POIs, deduping near duplicates and preferring Overture
34    /// representatives for duplicate groups.
35    Both,
36    /// Prefer Overture POIs, with OSM POIs as fallback when Overture is missing or
37    /// returns no POIs.
38    #[default]
39    OverturePreferred,
40}
41
42/// How [`fetch_map_data`] handles Overture fetch failures when Overture is enabled.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum OvertureFailureMode {
46    /// Return OSM data with a warning when Overture fails.
47    #[default]
48    FallbackToOsm,
49    /// Return an error when Overture fails.
50    Fail,
51}
52
53/// Configuration for [`fetch_map_data`].
54#[derive(Debug, Clone)]
55pub struct SourceOptions {
56    /// Feature categories to request from OSM/Overpass.
57    pub filter: FeatureFilter,
58    /// Explicit Overpass endpoint. `None` uses [`crate::overpass::default_overpass_url`].
59    pub overpass_url: Option<String>,
60    /// Whether to read existing raw Overpass cache entries before fetching.
61    /// Freshly fetched Overpass XML is still written to cache on success.
62    pub use_overpass_cache: bool,
63    /// Overture Maps fetch configuration. Overture is skipped unless `enabled` is `true`.
64    pub overture: OvertureParams,
65    /// Policy for final POI source selection and dedupe.
66    pub poi_source_mode: PoiSourceMode,
67    /// Failure policy for Overture fetch errors.
68    pub overture_failure_mode: OvertureFailureMode,
69}
70
71impl Default for SourceOptions {
72    fn default() -> Self {
73        Self {
74            filter: FeatureFilter::default(),
75            overpass_url: None,
76            use_overpass_cache: true,
77            overture: OvertureParams::default(),
78            poi_source_mode: PoiSourceMode::OverturePreferred,
79            overture_failure_mode: OvertureFailureMode::FallbackToOsm,
80        }
81    }
82}
83
84/// Effective source outcome after fetching and merging.
85#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum SourceStatus {
88    /// Output contains OSM POIs only.
89    OsmOnly,
90    /// Output contains Overture POIs only.
91    OvertureOnly,
92    /// Output merged both sources with dedupe.
93    Both,
94    /// Output preferred Overture POIs successfully.
95    OverturePreferred,
96    /// Overture was requested but unavailable, failed, or returned no POIs; OSM POIs were used.
97    OvertureFallbackToOsm,
98}
99
100/// Data and metadata returned by [`fetch_map_data`] and [`merge_source_data`].
101pub struct SourceFetchResult {
102    /// Normalized map data after source merge policy has been applied.
103    pub data: OsmData,
104    /// Effective source outcome.
105    pub status: SourceStatus,
106    /// Human-readable non-fatal warnings, usually Overture fallback reasons.
107    pub warnings: Vec<String>,
108}
109
110fn normalized_name(tags: &HashMap<String, String>) -> Option<String> {
111    tags.get("name")
112        .map(|name| name.trim().to_lowercase())
113        .filter(|name| !name.is_empty())
114}
115
116fn poi_category(tags: &HashMap<String, String>) -> String {
117    for key in [
118        "amenity", "shop", "tourism", "leisure", "historic", "man_made",
119    ] {
120        if let Some(value) = tags.get(key) {
121            return format!("{key}:{value}");
122        }
123    }
124    "unknown".to_string()
125}
126
127fn metres_between(a: &OsmPoiNode, b: &OsmPoiNode) -> f64 {
128    let mean_lat = ((a.lat + b.lat) * 0.5).to_radians();
129    let metres_per_degree_lat = 111_320.0;
130    let metres_per_degree_lon = 111_320.0 * mean_lat.cos().abs().max(0.01);
131    let dx = (a.lon - b.lon) * metres_per_degree_lon;
132    let dz = (a.lat - b.lat) * metres_per_degree_lat;
133    (dx * dx + dz * dz).sqrt()
134}
135
136fn poi_duplicates(a: &OsmPoiNode, b: &OsmPoiNode) -> bool {
137    let same_category = poi_category(&a.tags) == poi_category(&b.tags);
138    if !same_category {
139        return false;
140    }
141    match (normalized_name(&a.tags), normalized_name(&b.tags)) {
142        (Some(a_name), Some(b_name)) if a_name == b_name => metres_between(a, b) <= 25.0,
143        (None, None) => metres_between(a, b) <= 10.0,
144        _ => false,
145    }
146}
147
148fn dedupe_pois_with_overture_preference(mut pois: Vec<OsmPoiNode>) -> Vec<OsmPoiNode> {
149    pois.sort_by_key(|poi| match poi.source {
150        FeatureSource::Overture => 0,
151        FeatureSource::Osm => 1,
152        FeatureSource::Synthetic => 2,
153    });
154
155    let mut kept: Vec<OsmPoiNode> = Vec::new();
156    'next_poi: for poi in pois {
157        for existing in &kept {
158            if poi_duplicates(existing, &poi) {
159                continue 'next_poi;
160            }
161        }
162        kept.push(poi);
163    }
164    kept
165}
166
167/// Merge already-loaded OSM and optional Overture data according to `poi_source_mode`.
168///
169/// Duplicate POIs are detected by category, normalized name, and distance. When
170/// both sources describe the same POI, the Overture representative is retained.
171/// This function performs no network or cache I/O.
172pub fn merge_source_data(
173    mut osm_data: OsmData,
174    overture_data: Option<OsmData>,
175    poi_source_mode: PoiSourceMode,
176) -> SourceFetchResult {
177    let original_osm_pois = osm_data.poi_nodes.clone();
178    let mut warnings = Vec::new();
179
180    match (poi_source_mode, overture_data) {
181        (PoiSourceMode::OsmOnly, Some(mut overture)) => {
182            overture.poi_nodes.clear();
183            osm_data.merge(overture);
184            osm_data.poi_nodes = original_osm_pois;
185            SourceFetchResult {
186                data: osm_data,
187                status: SourceStatus::OsmOnly,
188                warnings,
189            }
190        }
191        (PoiSourceMode::OsmOnly, None) => SourceFetchResult {
192            data: osm_data,
193            status: SourceStatus::OsmOnly,
194            warnings,
195        },
196        (PoiSourceMode::OvertureOnly, Some(mut overture)) => {
197            let overture_pois = overture.poi_nodes.clone();
198            osm_data.poi_nodes = overture_pois;
199            overture.poi_nodes.clear();
200            osm_data.merge(overture);
201            SourceFetchResult {
202                data: osm_data,
203                status: SourceStatus::OvertureOnly,
204                warnings,
205            }
206        }
207        (PoiSourceMode::OvertureOnly, None) => {
208            osm_data.poi_nodes.clear();
209            warnings.push("Overture POIs unavailable for overture-only mode".to_string());
210            SourceFetchResult {
211                data: osm_data,
212                status: SourceStatus::OvertureOnly,
213                warnings,
214            }
215        }
216        (PoiSourceMode::Both, Some(mut overture)) => {
217            let mut all_pois = original_osm_pois;
218            all_pois.extend(overture.poi_nodes.clone());
219            overture.poi_nodes.clear();
220            osm_data.merge(overture);
221            osm_data.poi_nodes = dedupe_pois_with_overture_preference(all_pois);
222            SourceFetchResult {
223                data: osm_data,
224                status: SourceStatus::Both,
225                warnings,
226            }
227        }
228        (PoiSourceMode::Both, None) => {
229            warnings.push("Overture POIs unavailable; using OSM POIs only".to_string());
230            SourceFetchResult {
231                data: osm_data,
232                status: SourceStatus::OvertureFallbackToOsm,
233                warnings,
234            }
235        }
236        (PoiSourceMode::OverturePreferred, Some(mut overture))
237            if !overture.poi_nodes.is_empty() =>
238        {
239            let mut all_pois = original_osm_pois;
240            all_pois.extend(overture.poi_nodes.clone());
241            overture.poi_nodes.clear();
242            osm_data.merge(overture);
243            osm_data.poi_nodes = dedupe_pois_with_overture_preference(all_pois);
244            SourceFetchResult {
245                data: osm_data,
246                status: SourceStatus::OverturePreferred,
247                warnings,
248            }
249        }
250        (PoiSourceMode::OverturePreferred, Some(mut overture)) => {
251            warnings.push("Overture returned no POIs; using OSM POIs only".to_string());
252            overture.poi_nodes.clear();
253            osm_data.merge(overture);
254            osm_data.poi_nodes = original_osm_pois;
255            SourceFetchResult {
256                data: osm_data,
257                status: SourceStatus::OvertureFallbackToOsm,
258                warnings,
259            }
260        }
261        (PoiSourceMode::OverturePreferred, None) => {
262            warnings.push("Overture POIs unavailable; using OSM POIs only".to_string());
263            SourceFetchResult {
264                data: osm_data,
265                status: SourceStatus::OvertureFallbackToOsm,
266                warnings,
267            }
268        }
269    }
270}
271
272fn emit_progress(
273    progress_cb: &mut dyn FnMut(f32, &str),
274    last_progress: &mut f32,
275    pct: f32,
276    message: &str,
277) {
278    let pct = if pct.is_finite() {
279        pct.clamp(0.0, 1.0)
280    } else {
281        *last_progress
282    };
283    if pct >= *last_progress {
284        *last_progress = pct;
285        progress_cb(pct, message);
286    }
287}
288
289pub(crate) fn fetch_map_data_with_fetchers<FetchOsm, FetchOverture>(
290    bbox: (f64, f64, f64, f64),
291    options: &SourceOptions,
292    progress_cb: &mut dyn FnMut(f32, &str),
293    mut fetch_osm: FetchOsm,
294    mut fetch_overture: FetchOverture,
295) -> Result<SourceFetchResult>
296where
297    FetchOsm: FnMut((f64, f64, f64, f64), &FeatureFilter, bool, &str) -> Result<OsmData>,
298    FetchOverture:
299        FnMut((f64, f64, f64, f64), &OvertureParams, &mut dyn FnMut(f32, &str)) -> Result<OsmData>,
300{
301    const OSM_DONE_PROGRESS: f32 = 0.45;
302    const OVERTURE_DONE_PROGRESS: f32 = 0.90;
303    const MERGE_PROGRESS: f32 = 0.95;
304
305    let mut last_progress = 0.0;
306    emit_progress(progress_cb, &mut last_progress, 0.0, "Fetching OSM data…");
307    let overpass_url = match options.overpass_url.as_deref() {
308        Some(url) => url,
309        None => crate::overpass::default_overpass_url(),
310    };
311    let osm_data = fetch_osm(
312        bbox,
313        &options.filter,
314        options.use_overpass_cache,
315        overpass_url,
316    )?;
317
318    let overture_data = if options.overture.enabled {
319        emit_progress(
320            progress_cb,
321            &mut last_progress,
322            OSM_DONE_PROGRESS,
323            "OSM data ready; fetching Overture data…",
324        );
325        let overture_params = options.overture.clone();
326        let mut overture_progress = |pct: f32, message: &str| {
327            let pct = if pct.is_finite() {
328                pct.clamp(0.0, 1.0)
329            } else {
330                0.0
331            };
332            let mapped = OSM_DONE_PROGRESS + pct * (OVERTURE_DONE_PROGRESS - OSM_DONE_PROGRESS);
333            emit_progress(progress_cb, &mut last_progress, mapped, message);
334        };
335        match fetch_overture(bbox, &overture_params, &mut overture_progress) {
336            Ok(data) => Some(data),
337            Err(err) if options.overture_failure_mode == OvertureFailureMode::FallbackToOsm => {
338                let warning = format!("Overture fetch failed: {err:#}");
339                log::warn!(
340                    "{warning}; continuing with configured POI source mode {:?}",
341                    options.poi_source_mode
342                );
343                let mut result = merge_source_data(osm_data, None, options.poi_source_mode);
344                result.warnings.push(warning);
345                emit_progress(
346                    progress_cb,
347                    &mut last_progress,
348                    MERGE_PROGRESS,
349                    "Merging map data…",
350                );
351                result.data.clip_to_bbox(bbox);
352                emit_progress(progress_cb, &mut last_progress, 1.0, "Map data ready");
353                return Ok(result);
354            }
355            Err(err) => return Err(err),
356        }
357    } else {
358        emit_progress(
359            progress_cb,
360            &mut last_progress,
361            OVERTURE_DONE_PROGRESS,
362            "OSM data ready",
363        );
364        None
365    };
366
367    emit_progress(
368        progress_cb,
369        &mut last_progress,
370        MERGE_PROGRESS,
371        "Merging map data…",
372    );
373    let mut result = merge_source_data(osm_data, overture_data, options.poi_source_mode);
374    result.data.clip_to_bbox(bbox);
375    emit_progress(progress_cb, &mut last_progress, 1.0, "Map data ready");
376    Ok(result)
377}
378
379/// Fetch OSM/Overpass data, optionally fetch Overture data, and apply source policy.
380///
381/// `bbox` is `(south, west, north, east)` in decimal degrees. `progress` receives
382/// monotonically increasing values in the range `0.0..=1.0` for the source fetch
383/// phase. The function uses blocking I/O and should be called from an appropriate
384/// worker thread in async/UI applications.
385///
386/// Overture fetches are gated by `options.overture.enabled`. If Overture is
387/// disabled, no Overture CLI check, cache read, or network request is performed
388/// even when `options.poi_source_mode` is [`PoiSourceMode::OverturePreferred`].
389pub fn fetch_map_data(
390    bbox: (f64, f64, f64, f64),
391    options: &SourceOptions,
392    progress_cb: &mut dyn FnMut(f32, &str),
393) -> Result<SourceFetchResult> {
394    fetch_map_data_with_fetchers(
395        bbox,
396        options,
397        progress_cb,
398        crate::overpass::fetch_osm_data,
399        crate::overture::fetch_overture_data,
400    )
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn source_options_default_uses_overture_preferred_with_fallback() {
409        let options = SourceOptions::default();
410
411        assert_eq!(options.poi_source_mode, PoiSourceMode::OverturePreferred);
412        assert_eq!(
413            options.overture_failure_mode,
414            OvertureFailureMode::FallbackToOsm
415        );
416        assert!(options.use_overpass_cache);
417    }
418
419    fn empty_data() -> OsmData {
420        OsmData {
421            nodes: HashMap::new(),
422            ways: Vec::new(),
423            ways_by_id: HashMap::new(),
424            relations: Vec::new(),
425            bounds: Some((0.0, 0.0, 1.0, 1.0)),
426            poi_nodes: Vec::new(),
427            addr_nodes: Vec::new(),
428            tree_nodes: Vec::new(),
429        }
430    }
431
432    fn poi(
433        lat: f64,
434        lon: f64,
435        key: &str,
436        value: &str,
437        name: &str,
438        source: FeatureSource,
439    ) -> OsmPoiNode {
440        let mut tags = HashMap::from([(key.to_string(), value.to_string())]);
441        if !name.is_empty() {
442            tags.insert("name".to_string(), name.to_string());
443        }
444        OsmPoiNode {
445            lat,
446            lon,
447            tags,
448            source,
449        }
450    }
451
452    fn test_bbox() -> (f64, f64, f64, f64) {
453        (0.0, 0.0, 1.0, 1.0)
454    }
455
456    #[test]
457    fn fetch_map_data_default_options_do_not_invoke_overture_fetcher() {
458        let options = SourceOptions::default();
459        let mut overture_called = false;
460        let mut progress = Vec::new();
461
462        let result = fetch_map_data_with_fetchers(
463            test_bbox(),
464            &options,
465            &mut |pct, message| progress.push((pct, message.to_string())),
466            |_, _, _, _| {
467                let mut osm = empty_data();
468                osm.poi_nodes.push(poi(
469                    0.5,
470                    0.5,
471                    "shop",
472                    "bakery",
473                    "Bakery",
474                    FeatureSource::Osm,
475                ));
476                Ok(osm)
477            },
478            |_, _, _| {
479                overture_called = true;
480                panic!("Overture fetcher should not be called when disabled");
481            },
482        )
483        .expect("fetch succeeds");
484
485        assert!(!overture_called);
486        assert_eq!(result.status, SourceStatus::OvertureFallbackToOsm);
487        assert_eq!(result.data.poi_nodes.len(), 1);
488        assert_eq!(result.data.poi_nodes[0].source, FeatureSource::Osm);
489        assert_eq!(progress.last().map(|(pct, _)| *pct), Some(1.0));
490    }
491
492    #[test]
493    fn fetch_map_data_enabled_overture_invokes_fetcher_and_dedupes_preferred_pois() {
494        let mut options = SourceOptions::default();
495        options.overture.enabled = true;
496        options.poi_source_mode = PoiSourceMode::OverturePreferred;
497        let mut overture_called = false;
498
499        let result = fetch_map_data_with_fetchers(
500            test_bbox(),
501            &options,
502            &mut |_, _| {},
503            |_, _, _, _| {
504                let mut osm = empty_data();
505                osm.poi_nodes.push(poi(
506                    0.50000,
507                    0.50000,
508                    "amenity",
509                    "restaurant",
510                    "Diner",
511                    FeatureSource::Osm,
512                ));
513                Ok(osm)
514            },
515            |_, params, progress| {
516                overture_called = true;
517                assert!(params.enabled);
518                progress(0.0, "Overture starting");
519                progress(1.0, "Overture done");
520                let mut overture = empty_data();
521                overture.poi_nodes.push(poi(
522                    0.50005,
523                    0.50005,
524                    "amenity",
525                    "restaurant",
526                    "Diner",
527                    FeatureSource::Overture,
528                ));
529                Ok(overture)
530            },
531        )
532        .expect("fetch succeeds");
533
534        assert!(overture_called);
535        assert_eq!(result.status, SourceStatus::OverturePreferred);
536        assert_eq!(result.data.poi_nodes.len(), 1);
537        assert_eq!(result.data.poi_nodes[0].source, FeatureSource::Overture);
538    }
539
540    #[test]
541    fn fetch_map_data_fallback_captures_overture_error_warning_and_keeps_osm_result() {
542        let mut options = SourceOptions::default();
543        options.overture.enabled = true;
544        options.poi_source_mode = PoiSourceMode::OverturePreferred;
545        options.overture_failure_mode = OvertureFailureMode::FallbackToOsm;
546
547        let result = fetch_map_data_with_fetchers(
548            test_bbox(),
549            &options,
550            &mut |_, _| {},
551            |_, _, _, _| {
552                let mut osm = empty_data();
553                osm.poi_nodes.push(poi(
554                    0.5,
555                    0.5,
556                    "shop",
557                    "bakery",
558                    "Bakery",
559                    FeatureSource::Osm,
560                ));
561                Ok(osm)
562            },
563            |_, _, _| anyhow::bail!("synthetic overture failure"),
564        )
565        .expect("fallback succeeds");
566
567        assert_eq!(result.status, SourceStatus::OvertureFallbackToOsm);
568        assert_eq!(result.data.poi_nodes.len(), 1);
569        assert_eq!(result.data.poi_nodes[0].source, FeatureSource::Osm);
570        assert!(
571            result
572                .warnings
573                .iter()
574                .any(|warning| warning.contains("synthetic overture failure"))
575        );
576    }
577
578    #[test]
579    fn fetch_map_data_strict_overture_failure_returns_error() {
580        let mut options = SourceOptions::default();
581        options.overture.enabled = true;
582        options.overture_failure_mode = OvertureFailureMode::Fail;
583
584        let err = match fetch_map_data_with_fetchers(
585            test_bbox(),
586            &options,
587            &mut |_, _| {},
588            |_, _, _, _| Ok(empty_data()),
589            |_, _, _| anyhow::bail!("strict overture failure"),
590        ) {
591            Ok(_) => panic!("strict mode should return Overture error"),
592            Err(err) => err,
593        };
594
595        assert!(err.to_string().contains("strict overture failure"));
596    }
597
598    #[test]
599    fn fetch_map_data_progress_is_monotonic_and_finishes_at_one() {
600        let mut options = SourceOptions::default();
601        options.overture.enabled = true;
602        let mut progress_values = Vec::new();
603
604        fetch_map_data_with_fetchers(
605            test_bbox(),
606            &options,
607            &mut |pct, _| progress_values.push(pct),
608            |_, _, _, _| Ok(empty_data()),
609            |_, _, progress| {
610                progress(0.0, "Overture reset to zero");
611                progress(0.5, "Overture halfway");
612                progress(1.0, "Overture complete");
613                Ok(empty_data())
614            },
615        )
616        .expect("fetch succeeds");
617
618        assert!(!progress_values.is_empty());
619        for window in progress_values.windows(2) {
620            assert!(
621                window[0] <= window[1],
622                "progress moved backwards: {progress_values:?}"
623            );
624        }
625        assert!(
626            progress_values[..progress_values.len() - 1]
627                .iter()
628                .all(|pct| *pct < 1.0)
629        );
630        assert_eq!(progress_values.last().copied(), Some(1.0));
631    }
632
633    #[test]
634    fn osm_only_keeps_osm_pois_and_reports_osm_only_status() {
635        let mut osm = empty_data();
636        osm.poi_nodes.push(poi(
637            0.0,
638            0.0,
639            "amenity",
640            "restaurant",
641            "Diner",
642            FeatureSource::Osm,
643        ));
644        let mut overture = empty_data();
645        overture.poi_nodes.push(poi(
646            0.0,
647            0.0,
648            "amenity",
649            "restaurant",
650            "Diner",
651            FeatureSource::Overture,
652        ));
653
654        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OsmOnly);
655
656        assert_eq!(merged.status, SourceStatus::OsmOnly);
657        assert_eq!(merged.data.poi_nodes.len(), 1);
658        assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Osm);
659    }
660
661    #[test]
662    fn overture_only_keeps_overture_pois() {
663        let mut osm = empty_data();
664        osm.poi_nodes.push(poi(
665            0.0,
666            0.0,
667            "amenity",
668            "restaurant",
669            "Diner",
670            FeatureSource::Osm,
671        ));
672        let mut overture = empty_data();
673        overture.poi_nodes.push(poi(
674            0.0,
675            0.0,
676            "amenity",
677            "restaurant",
678            "Diner",
679            FeatureSource::Overture,
680        ));
681
682        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OvertureOnly);
683
684        assert_eq!(merged.data.poi_nodes.len(), 1);
685        assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Overture);
686    }
687
688    #[test]
689    fn overture_only_without_overture_clears_osm_pois_and_warns() {
690        let mut osm = empty_data();
691        osm.poi_nodes.push(poi(
692            0.0,
693            0.0,
694            "shop",
695            "bakery",
696            "Bakery",
697            FeatureSource::Osm,
698        ));
699
700        let merged = merge_source_data(osm, None, PoiSourceMode::OvertureOnly);
701
702        assert_eq!(merged.status, SourceStatus::OvertureOnly);
703        assert!(merged.data.poi_nodes.is_empty());
704        assert_eq!(
705            merged.warnings,
706            vec!["Overture POIs unavailable for overture-only mode".to_string()]
707        );
708    }
709
710    #[test]
711    fn both_dedupes_duplicate_pois_with_overture_winning_and_reports_both_status() {
712        let mut osm = empty_data();
713        osm.poi_nodes.push(poi(
714            51.50000,
715            -0.10000,
716            "amenity",
717            "restaurant",
718            "Diner",
719            FeatureSource::Osm,
720        ));
721        let mut overture = empty_data();
722        overture.poi_nodes.push(poi(
723            51.50005,
724            -0.10005,
725            "amenity",
726            "restaurant",
727            "Diner",
728            FeatureSource::Overture,
729        ));
730
731        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::Both);
732
733        assert_eq!(merged.status, SourceStatus::Both);
734        assert_eq!(merged.data.poi_nodes.len(), 1);
735        assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Overture);
736    }
737
738    #[test]
739    fn same_name_with_category_mismatch_keeps_both_pois() {
740        let mut osm = empty_data();
741        osm.poi_nodes.push(poi(
742            51.50000,
743            -0.10000,
744            "amenity",
745            "restaurant",
746            "Corner",
747            FeatureSource::Osm,
748        ));
749        let mut overture = empty_data();
750        overture.poi_nodes.push(poi(
751            51.50005,
752            -0.10005,
753            "shop",
754            "bakery",
755            "Corner",
756            FeatureSource::Overture,
757        ));
758
759        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::Both);
760
761        assert_eq!(merged.data.poi_nodes.len(), 2);
762        assert!(
763            merged
764                .data
765                .poi_nodes
766                .iter()
767                .any(|poi| poi.source == FeatureSource::Osm)
768        );
769        assert!(
770            merged
771                .data
772                .poi_nodes
773                .iter()
774                .any(|poi| poi.source == FeatureSource::Overture)
775        );
776    }
777
778    #[test]
779    fn overture_preferred_dedupes_named_pois_with_overture_winning_and_reports_success() {
780        let mut osm = empty_data();
781        osm.poi_nodes.push(poi(
782            51.50000,
783            -0.10000,
784            "amenity",
785            "restaurant",
786            "Diner",
787            FeatureSource::Osm,
788        ));
789        let mut overture = empty_data();
790        overture.poi_nodes.push(poi(
791            51.50005,
792            -0.10005,
793            "amenity",
794            "restaurant",
795            "Diner",
796            FeatureSource::Overture,
797        ));
798
799        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OverturePreferred);
800
801        assert_eq!(merged.status, SourceStatus::OverturePreferred);
802        assert_eq!(merged.data.poi_nodes.len(), 1);
803        assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Overture);
804    }
805
806    #[test]
807    fn overture_preferred_falls_back_when_overture_missing() {
808        let mut osm = empty_data();
809        osm.poi_nodes.push(poi(
810            0.0,
811            0.0,
812            "shop",
813            "bakery",
814            "Bakery",
815            FeatureSource::Osm,
816        ));
817
818        let merged = merge_source_data(osm, None, PoiSourceMode::OverturePreferred);
819
820        assert_eq!(merged.status, SourceStatus::OvertureFallbackToOsm);
821        assert_eq!(merged.data.poi_nodes.len(), 1);
822        assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Osm);
823        assert!(
824            merged
825                .warnings
826                .iter()
827                .any(|warning| warning.contains("Overture POIs unavailable"))
828        );
829    }
830
831    #[test]
832    fn overture_preferred_falls_back_precisely_when_overture_returns_zero_pois() {
833        let mut osm = empty_data();
834        osm.poi_nodes.push(poi(
835            0.0,
836            0.0,
837            "shop",
838            "bakery",
839            "Bakery",
840            FeatureSource::Osm,
841        ));
842        let overture = empty_data();
843
844        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OverturePreferred);
845
846        assert_eq!(merged.status, SourceStatus::OvertureFallbackToOsm);
847        assert_eq!(merged.data.poi_nodes.len(), 1);
848        assert_eq!(merged.data.poi_nodes[0].source, FeatureSource::Osm);
849        assert_eq!(
850            merged.warnings,
851            vec!["Overture returned no POIs; using OSM POIs only".to_string()]
852        );
853    }
854
855    #[test]
856    fn non_poi_overture_tree_nodes_are_preserved_when_pois_are_filtered() {
857        let mut osm = empty_data();
858        osm.poi_nodes.push(poi(
859            0.0,
860            0.0,
861            "shop",
862            "bakery",
863            "Bakery",
864            FeatureSource::Osm,
865        ));
866        let mut overture = empty_data();
867        overture.tree_nodes.push(crate::osm::OsmNode {
868            lat: 51.5,
869            lon: -0.1,
870        });
871
872        let merged = merge_source_data(osm, Some(overture), PoiSourceMode::OverturePreferred);
873
874        assert_eq!(merged.status, SourceStatus::OvertureFallbackToOsm);
875        assert_eq!(merged.data.poi_nodes.len(), 1);
876        assert_eq!(merged.data.tree_nodes.len(), 1);
877        assert_eq!(merged.data.tree_nodes[0].lat, 51.5);
878        assert_eq!(merged.data.tree_nodes[0].lon, -0.1);
879    }
880}