rmpca 0.1.1

Enterprise-grade route optimization engine — Chinese Postman Problem solver with Eulerian circuit detection, Lean 4 FFI boundary, and property-based testing
Documentation
//! Geocoding support — extract named places from OSM PBF for offline search
//!
//! This module defines types for named places and provides a stub for
//! PBF extraction. Full extraction requires the `osmpbfreader` crate
//! to be vendored.

use crate::geo::types::Coordinate;
use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;

/// OSM element type
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsmElementType {
    Node,
    Way,
    Relation,
}

impl OsmElementType {
    /// Convert to Photon-compatible single-character code
    #[allow(dead_code)]
    #[must_use]
    pub fn to_code(self) -> &'static str {
        match self {
            OsmElementType::Node => "N",
            OsmElementType::Way => "W",
            OsmElementType::Relation => "R",
        }
    }
}

/// A named place extracted from OSM data
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct NamedPlace {
    /// OSM element ID
    pub osm_id: i64,
    /// OSM element type (node, way, relation)
    pub osm_type: OsmElementType,
    /// Primary name
    pub name: String,
    /// Latitude (centroid for ways/relations)
    pub lat: f64,
    /// Longitude (centroid for ways/relations)
    pub lon: f64,
    /// All tags from the OSM element
    pub tags: HashMap<String, String>,
}

impl NamedPlace {
    /// Create a new named place
    #[allow(dead_code)]
    #[must_use]
    pub fn new(osm_id: i64, osm_type: OsmElementType, name: String, lat: f64, lon: f64) -> Self {
        Self {
            osm_id,
            osm_type,
            name,
            lat,
            lon,
            tags: HashMap::new(),
        }
    }

    /// Add a tag
    #[allow(dead_code)]
    #[must_use]
    pub fn with_tag(mut self, key: String, value: String) -> Self {
        self.tags.insert(key, value);
        self
    }

    /// Add multiple tags
    #[allow(dead_code)]
    #[must_use]
    pub fn with_tags(mut self, tags: HashMap<String, String>) -> Self {
        self.tags.extend(tags);
        self
    }

    /// Get the primary OSM key (e.g., "amenity", "shop", "highway")
    #[allow(dead_code)]
    #[must_use]
    pub fn osm_key(&self) -> Option<&str> {
        ["amenity", "shop", "tourism", "historic", "leisure", "office",
         "public_transport", "highway", "building", "place", "natural", "landuse"]
            .iter()
            .find(|&&key| self.tags.contains_key(key))
            .copied()
    }

    /// Get the primary OSM value (e.g., "restaurant", "cafe", "residential")
    #[allow(dead_code)]
    pub fn osm_value(&self) -> Option<&str> {
        if let Some(key) = self.osm_key() {
            self.tags.get(key).map(String::as_str)
        } else {
            None
        }
    }

    /// Get city/town name from tags
    #[allow(dead_code)]
    pub fn city(&self) -> Option<&str> {
        self.tags.get("addr:city")
            .or_else(|| self.tags.get("city"))
            .or_else(|| self.tags.get("town"))
            .or_else(|| self.tags.get("village"))
            .map(String::as_str)
    }

    /// Get street name from tags
    #[allow(dead_code)]
    pub fn street(&self) -> Option<&str> {
        self.tags.get("addr:street")
            .or_else(|| self.tags.get("street"))
            .map(String::as_str)
    }

    /// Get house number from tags
    #[allow(dead_code)]
    pub fn housenumber(&self) -> Option<&str> {
        self.tags.get("addr:housenumber")
            .or_else(|| self.tags.get("housenumber"))
            .map(String::as_str)
    }

    /// Get postcode from tags
    #[allow(dead_code)]
    pub fn postcode(&self) -> Option<&str> {
        self.tags.get("addr:postcode")
            .or_else(|| self.tags.get("postcode"))
            .map(String::as_str)
    }

    /// Get country code from tags
    #[allow(dead_code)]
    pub fn countrycode(&self) -> Option<&str> {
        self.tags.get("addr:country")
            .or_else(|| self.tags.get("country_code"))
            .or_else(|| self.tags.get("country"))
            .map(String::as_str)
    }
}

/// Tags that indicate a searchable named place
#[allow(dead_code)]
const SEARCHABLE_TAGS: &[&str] = &[
    "name",
    "addr:housenumber",
    "addr:street",
    "addr:city",
    "addr:postcode",
    "amenity",
    "shop",
    "tourism",
    "historic",
    "leisure",
    "office",
    "craft",
    "emergency",
    "healthcare",
    "public_transport",
    "highway",
    "place",
    "natural",
    "waterway",
    "landuse",
    "building",
    "aeroway",
    "railway",
    "boundary",
];

/// Check if an OSM element has searchable tags
#[allow(dead_code)]
fn has_searchable_tags(tags: &HashMap<String, String>) -> bool {
    if tags.contains_key("name") {
        return true;
    }
    if tags.contains_key("addr:housenumber") && tags.contains_key("addr:street") {
        return true;
    }
    if tags.contains_key("place") {
        return true;
    }
    false
}

/// Extract the primary name from tags
#[allow(dead_code)]
fn extract_name(tags: &HashMap<String, String>) -> Option<String> {
    if let Some(name) = tags.get("name") {
        return Some(name.clone());
    }
    for alt_key in &["alt_name", "official_name", "loc_name", "short_name"] {
        if let Some(name) = tags.get(*alt_key) {
            return Some(name.clone());
        }
    }
    if let (Some(housenumber), Some(street)) =
        (tags.get("addr:housenumber"), tags.get("addr:street")) {
        return Some(format!("{housenumber} {street}"));
    }
    if let Some(place_type) = tags.get("place") {
        return Some(format!("Unnamed {place_type}"));
    }
    None
}

/// Extract named places from a .osm.pbf file.
///
/// Requires the `osmpbfreader` crate to be vendored. Returns an error until then.
///
/// # Errors
/// Returns an error if the `osmpbfreader` crate is not available or if the
/// extraction fails.
#[allow(dead_code)]
pub fn extract_named_places<P: AsRef<Path>>(
    _file_path: P,
    _bbox: Option<(f64, f64, f64, f64)>,
) -> Result<Vec<NamedPlace>> {
    anyhow::bail!(
        "OSM PBF extraction requires the osmpbfreader crate. \
         Vendor it and enable the 'osm' feature to use this function."
    )
}

/// Calculate centroid of a set of coordinates
#[allow(dead_code)]
#[allow(clippy::cast_precision_loss)]
fn centroid(coords: &[Coordinate]) -> (f64, f64) {
    if coords.is_empty() {
        return (0.0, 0.0);
    }

    let sum_lat: f64 = coords.iter().map(|c| c.lat).sum();
    let sum_lon: f64 = coords.iter().map(|c| c.lon).sum();
    let n = coords.len() as f64;

    (sum_lat / n, sum_lon / n)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_has_searchable_tags() {
        let mut tags = HashMap::new();
        tags.insert("name".to_string(), "Test".to_string());
        assert!(has_searchable_tags(&tags));

        let mut tags2 = HashMap::new();
        tags2.insert("addr:housenumber".to_string(), "123".to_string());
        tags2.insert("addr:street".to_string(), "Main St".to_string());
        assert!(has_searchable_tags(&tags2));

        let mut tags3 = HashMap::new();
        tags3.insert("building".to_string(), "yes".to_string());
        assert!(!has_searchable_tags(&tags3));
    }

    #[test]
    fn test_extract_name() {
        let mut tags = HashMap::new();
        tags.insert("name".to_string(), "Cafe Example".to_string());
        assert_eq!(extract_name(&tags), Some("Cafe Example".to_string()));

        let mut tags2 = HashMap::new();
        tags2.insert("addr:housenumber".to_string(), "42".to_string());
        tags2.insert("addr:street".to_string(), "Oak Street".to_string());
        assert_eq!(extract_name(&tags2), Some("42 Oak Street".to_string()));
    }

    #[test]
    fn test_named_place_accessors() {
        let mut tags = HashMap::new();
        tags.insert("amenity".to_string(), "cafe".to_string());
        tags.insert("addr:city".to_string(), "Montreal".to_string());
        tags.insert("addr:street".to_string(), "Rue Saint-Denis".to_string());
        tags.insert("addr:housenumber".to_string(), "123".to_string());

        let place = NamedPlace::new(12345, OsmElementType::Node, "Cafe".to_string(), 45.5, -73.6)
            .with_tags(tags);

        assert_eq!(place.osm_key(), Some("amenity"));
        assert_eq!(place.osm_value(), Some("cafe"));
        assert_eq!(place.city(), Some("Montreal"));
        assert_eq!(place.street(), Some("Rue Saint-Denis"));
        assert_eq!(place.housenumber(), Some("123"));
    }

    #[test]
    fn test_centroid() {
        let coords = vec![
            Coordinate::new(45.0, -73.0),
            Coordinate::new(46.0, -74.0),
        ];
        let (lat, lon) = centroid(&coords);
        assert!((lat - 45.5).abs() < 1e-6);
        assert!((lon - (-73.5)).abs() < 1e-6);
    }
}