lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 710fb2feaf6fc1ace7f4fe77a451e4fb6fa25e54
parent 218c97e948548d2360a4e78e8b5192288b4fbb67
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 23:13:00 +0000

geocoder: add forward locality lookup

- add typed locality query, candidate, and lookup result models
- resolve structured, free-form, and GeoNames id locality inputs deterministically
- cover unique, no-match, ambiguous, region alias, and id-selected lookup paths

Diffstat:
Mcrates/geocoder/src/geocoder.rs | 452++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/geocoder/src/lib.rs | 4+++-
Mcrates/geocoder/src/model.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/geocoder/tests/geocoder.rs | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 646 insertions(+), 3 deletions(-)

diff --git a/crates/geocoder/src/geocoder.rs b/crates/geocoder/src/geocoder.rs @@ -1,7 +1,9 @@ use crate::asset::{GeoNamesAssetSpec, validate_geonames_asset_file}; use crate::error::GeocoderError; use crate::model::{ - GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, + GeocoderCountryListResult, GeocoderLocalityCandidate, GeocoderLocalityInput, + GeocoderLocalityLookup, GeocoderLocalityQuery, GeocoderPoint, GeocoderReverseOptions, + GeocoderReverseResult, }; use rusqlite::{Connection, OpenFlags, named_params}; use std::io::Write; @@ -130,6 +132,117 @@ impl Geocoder { pub fn country_center(&self, country_id: &str) -> Result<GeocoderPoint, GeocoderError> { finalize_country_center(country_center_impl(&self.conn, country_id), country_id) } + + pub fn locality( + &self, + query: &GeocoderLocalityQuery, + ) -> Result<GeocoderLocalityLookup, GeocoderError> { + match &query.input { + GeocoderLocalityInput::Structured(structured) => self.locality_by_parts( + &structured.locality, + structured.region.as_deref(), + structured.country.as_deref(), + query.limit, + ), + GeocoderLocalityInput::Query(query_text) => { + let parsed = parse_locality_query(query_text); + self.locality_by_parts( + &parsed.locality, + parsed.region.as_deref(), + parsed.country.as_deref(), + query.limit, + ) + } + GeocoderLocalityInput::FeatureId(id) => self.locality_by_feature_id(*id), + } + } + + fn locality_by_parts( + &self, + locality: &str, + region: Option<&str>, + country: Option<&str>, + limit: usize, + ) -> Result<GeocoderLocalityLookup, GeocoderError> { + let Some(locality) = normalize_optional_name(Some(locality)) else { + return Ok(GeocoderLocalityLookup::NoMatch); + }; + let country = normalize_optional_name(country); + let mut stmt = self.conn.prepare( + r#" + SELECT + id, + name, + admin1_id, + admin1_name, + country_id, + country_name, + latitude, + longitude + FROM geonames + WHERE lower(name) = :locality + AND ( + :country IS NULL + OR lower(country_id) = :country + OR lower(country_name) = :country + ) + ORDER BY + lower(name) ASC, + lower(country_id) ASC, + lower(coalesce(country_name, '')) ASC, + lower(coalesce(admin1_name, '')) ASC, + coalesce(admin1_id, -1) ASC, + id ASC + "#, + )?; + let candidates = collect_mapped_rows( + &mut stmt, + named_params! { + ":locality": locality, + ":country": country, + }, + map_locality_candidate_row, + )?; + let region = normalize_optional_name(region); + let candidates = candidates + .into_iter() + .filter(|candidate| locality_region_matches(candidate, region.as_deref())) + .collect::<Vec<_>>(); + Ok(finalize_locality_lookup(candidates, limit)) + } + + fn locality_by_feature_id(&self, id: i64) -> Result<GeocoderLocalityLookup, GeocoderError> { + let mut stmt = self.conn.prepare( + r#" + SELECT + id, + name, + admin1_id, + admin1_name, + country_id, + country_name, + latitude, + longitude + FROM geonames + WHERE id = :id + LIMIT 1 + "#, + )?; + let candidates = collect_mapped_rows( + &mut stmt, + named_params! { + ":id": id, + }, + map_locality_candidate_row, + )?; + Ok(finalize_locality_lookup(candidates, 1)) + } +} + +struct ParsedLocalityQuery { + locality: String, + region: Option<String>, + country: Option<String>, } fn query_country_center_row( @@ -207,6 +320,211 @@ fn map_reverse_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GeocoderReverseR }) } +fn map_locality_candidate_row( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result<GeocoderLocalityCandidate> { + let name = row.get("name")?; + let admin1_name = row.get("admin1_name")?; + let country_name = row.get("country_name")?; + let candidate = GeocoderLocalityCandidate { + id: row.get("id")?, + name, + admin1_id: row.get("admin1_id")?, + admin1_name, + country_id: row.get("country_id")?, + country_name, + point: GeocoderPoint { + lat: row.get("latitude")?, + lng: row.get("longitude")?, + }, + display_name: String::new(), + }; + Ok(GeocoderLocalityCandidate { + display_name: locality_candidate_display_name(&candidate), + ..candidate + }) +} + +fn parse_locality_query(query: &str) -> ParsedLocalityQuery { + let parts = query + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect::<Vec<_>>(); + match parts.as_slice() { + [] => ParsedLocalityQuery { + locality: String::new(), + region: None, + country: None, + }, + [locality] => ParsedLocalityQuery { + locality: locality.clone(), + region: None, + country: None, + }, + [locality, region] => ParsedLocalityQuery { + locality: locality.clone(), + region: Some(region.clone()), + country: None, + }, + parts => { + let country = parts.last().cloned(); + let region = parts.get(parts.len().saturating_sub(2)).cloned(); + let locality = parts[..parts.len().saturating_sub(2)].join(", "); + ParsedLocalityQuery { + locality, + region, + country, + } + } + } +} + +fn finalize_locality_lookup( + mut candidates: Vec<GeocoderLocalityCandidate>, + limit: usize, +) -> GeocoderLocalityLookup { + match candidates.len() { + 0 => GeocoderLocalityLookup::NoMatch, + 1 => GeocoderLocalityLookup::Unique { + candidate: candidates.remove(0), + }, + _ => { + candidates.truncate(limit.max(1)); + GeocoderLocalityLookup::Ambiguous { candidates } + } + } +} + +fn locality_region_matches(candidate: &GeocoderLocalityCandidate, region: Option<&str>) -> bool { + let Some(region) = region else { + return true; + }; + let Some(admin1_name) = candidate.admin1_name.as_deref() else { + return false; + }; + if normalize_name(admin1_name) == region { + return true; + } + let region_code = normalize_region_code(region); + region_aliases(&candidate.country_id) + .iter() + .any(|(code, name)| { + normalize_region_code(code) == region_code + && normalize_name(name) == normalize_name(admin1_name) + }) +} + +fn locality_candidate_display_name(candidate: &GeocoderLocalityCandidate) -> String { + let mut parts = vec![candidate.name.clone()]; + if let Some(admin1_name) = candidate.admin1_name.as_ref() { + parts.push(admin1_name.clone()); + } + if let Some(country_name) = candidate.country_name.as_ref() { + parts.push(country_name.clone()); + } else { + parts.push(candidate.country_id.clone()); + } + parts.join(", ") +} + +fn normalize_optional_name(input: Option<&str>) -> Option<String> { + input + .map(normalize_name) + .filter(|normalized| !normalized.is_empty()) +} + +fn normalize_name(input: &str) -> String { + input + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") + .to_ascii_lowercase() +} + +fn normalize_region_code(input: &str) -> String { + input + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_uppercase()) + .collect() +} + +fn region_aliases(country_id: &str) -> &'static [(&'static str, &'static str)] { + match country_id.to_ascii_uppercase().as_str() { + "CA" => &[ + ("AB", "Alberta"), + ("BC", "British Columbia"), + ("MB", "Manitoba"), + ("NB", "New Brunswick"), + ("NL", "Newfoundland and Labrador"), + ("NS", "Nova Scotia"), + ("NT", "Northwest Territories"), + ("NU", "Nunavut"), + ("ON", "Ontario"), + ("PE", "Prince Edward Island"), + ("QC", "Quebec"), + ("SK", "Saskatchewan"), + ("YT", "Yukon"), + ], + "US" => &[ + ("AL", "Alabama"), + ("AK", "Alaska"), + ("AZ", "Arizona"), + ("AR", "Arkansas"), + ("CA", "California"), + ("CO", "Colorado"), + ("CT", "Connecticut"), + ("DC", "District of Columbia"), + ("DE", "Delaware"), + ("FL", "Florida"), + ("GA", "Georgia"), + ("HI", "Hawaii"), + ("IA", "Iowa"), + ("ID", "Idaho"), + ("IL", "Illinois"), + ("IN", "Indiana"), + ("KS", "Kansas"), + ("KY", "Kentucky"), + ("LA", "Louisiana"), + ("MA", "Massachusetts"), + ("MD", "Maryland"), + ("ME", "Maine"), + ("MI", "Michigan"), + ("MN", "Minnesota"), + ("MO", "Missouri"), + ("MS", "Mississippi"), + ("MT", "Montana"), + ("NC", "North Carolina"), + ("ND", "North Dakota"), + ("NE", "Nebraska"), + ("NH", "New Hampshire"), + ("NJ", "New Jersey"), + ("NM", "New Mexico"), + ("NV", "Nevada"), + ("NY", "New York"), + ("OH", "Ohio"), + ("OK", "Oklahoma"), + ("OR", "Oregon"), + ("PA", "Pennsylvania"), + ("RI", "Rhode Island"), + ("SC", "South Carolina"), + ("SD", "South Dakota"), + ("TN", "Tennessee"), + ("TX", "Texas"), + ("UT", "Utah"), + ("VA", "Virginia"), + ("VT", "Vermont"), + ("WA", "Washington"), + ("WI", "Wisconsin"), + ("WV", "West Virginia"), + ("WY", "Wyoming"), + ], + _ => &[], + } +} + #[cfg(test)] mod tests { use super::*; @@ -273,6 +591,88 @@ mod tests { } #[test] + fn unit_harness_covers_forward_locality_lookup_modes() { + let geocoder = open_forward_fixture_geocoder(); + + let british_columbia = geocoder + .locality( + &GeocoderLocalityQuery::structured("Fixture Victoria") + .with_region("BC") + .with_country("CA"), + ) + .expect("structured locality lookup"); + assert_unique_locality( + british_columbia, + 3001, + "Fixture Victoria, British Columbia, Canada", + ); + + let country_name = geocoder + .locality( + &GeocoderLocalityQuery::structured("Fixture Victoria") + .with_region("British Columbia") + .with_country("Canada"), + ) + .expect("structured country-name locality lookup"); + assert_unique_locality( + country_name, + 3001, + "Fixture Victoria, British Columbia, Canada", + ); + + let freeform = geocoder + .locality(&GeocoderLocalityQuery::query("Fixture Victoria, BC, CA")) + .expect("freeform locality lookup"); + assert_unique_locality(freeform, 3001, "Fixture Victoria, British Columbia, Canada"); + + let narrowed = geocoder + .locality( + &GeocoderLocalityQuery::structured("Shared Market") + .with_region("Prairie Region") + .with_country("CA"), + ) + .expect("region-narrowed locality lookup"); + assert_unique_locality(narrowed, 3003, "Shared Market, Prairie Region, Canada"); + + let selected = geocoder + .locality(&GeocoderLocalityQuery::feature_id(3004)) + .expect("feature-id locality lookup"); + assert_unique_locality(selected, 3004, "Identifier Grove, British Columbia, Canada"); + + let no_match = geocoder + .locality( + &GeocoderLocalityQuery::structured("Missing Market") + .with_region("BC") + .with_country("CA"), + ) + .expect("no-match locality lookup"); + assert!(matches!(no_match, GeocoderLocalityLookup::NoMatch)); + } + + #[test] + fn unit_harness_covers_forward_locality_ambiguity_and_limits() { + let geocoder = open_forward_fixture_geocoder(); + + let ambiguous = geocoder + .locality( + &GeocoderLocalityQuery::structured("Shared Market") + .with_country("CA") + .with_limit(1), + ) + .expect("ambiguous locality lookup"); + + let GeocoderLocalityLookup::Ambiguous { candidates } = ambiguous else { + panic!("expected ambiguous locality lookup"); + }; + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].id, 3002); + assert_eq!( + candidates[0].display_name, + "Shared Market, British Columbia, Canada" + ); + } + + #[test] fn unit_harness_covers_open_path_pathbuf_instantiation() { let path = build_fixture_database(); let geocoder = Geocoder::open_path(path.to_path_buf()).expect("open geocoder from pathbuf"); @@ -552,6 +952,11 @@ mod tests { Geocoder::open_path(&path).expect("open empty geocoder") } + fn open_forward_fixture_geocoder() -> Geocoder { + let path = build_forward_fixture_database(); + Geocoder::open_path(&path).expect("open forward geocoder") + } + fn open_reverse_country_row_error_geocoder() -> Geocoder { let temp = NamedTempFile::new().expect("temp db"); let path = temp.into_temp_path(); @@ -580,6 +985,13 @@ mod tests { path } + fn build_forward_fixture_database() -> tempfile::TempPath { + let temp = NamedTempFile::new().expect("temp db"); + let path = temp.into_temp_path(); + seed_forward_fixture_database(path.to_str().expect("utf-8 temp path")); + path + } + fn geocoder_with_reverse_country_query_execution_error() -> Geocoder { let conn = Connection::open_in_memory().expect("open in-memory query error db"); conn.execute_batch( @@ -745,6 +1157,32 @@ mod tests { insert_feature(&conn, 2, "Polar North", "NO", 1, 75.05, 0.05); } + fn seed_forward_fixture_database(path: &str) { + let conn = Connection::open(path).expect("open fixture database"); + seed_schema(&conn); + + insert_country(&conn, "CA", "Canada"); + insert_country(&conn, "US", "United States"); + + insert_admin1(&conn, "CA", 2, "British Columbia"); + insert_admin1(&conn, "CA", 3, "Prairie Region"); + insert_admin1(&conn, "US", 4, "River Region"); + + insert_feature( + &conn, + 3001, + "Fixture Victoria", + "CA", + 2, + 48.4359, + -123.35155, + ); + insert_feature(&conn, 3002, "Shared Market", "CA", 2, 48.7, -123.2); + insert_feature(&conn, 3003, "Shared Market", "CA", 3, 50.2, -110.4); + insert_feature(&conn, 3004, "Identifier Grove", "CA", 2, 48.9, -123.4); + insert_feature(&conn, 3005, "Query Hamlet", "US", 4, 39.25, -77.5); + } + fn seed_reverse_country_row_error_database(path: &str) { let conn = Connection::open(path).expect("open invalid row fixture database"); conn.execute_batch( @@ -905,4 +1343,16 @@ mod tests { other => panic!("expected CountryCenterNotFound, got {other}"), } } + + fn assert_unique_locality( + lookup: GeocoderLocalityLookup, + expected_id: i64, + expected_display_name: &str, + ) { + let GeocoderLocalityLookup::Unique { candidate } = lookup else { + panic!("expected unique locality lookup"); + }; + assert_eq!(candidate.id, expected_id); + assert_eq!(candidate.display_name, expected_display_name); + } } diff --git a/crates/geocoder/src/lib.rs b/crates/geocoder/src/lib.rs @@ -18,5 +18,7 @@ pub use asset::{ pub use error::GeocoderError; pub use geocoder::Geocoder; pub use model::{ - GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, + GeocoderCountryListResult, GeocoderLocalityCandidate, GeocoderLocalityInput, + GeocoderLocalityLookup, GeocoderLocalityQuery, GeocoderPoint, GeocoderReverseOptions, + GeocoderReverseResult, GeocoderStructuredLocalityQuery, }; diff --git a/crates/geocoder/src/model.rs b/crates/geocoder/src/model.rs @@ -40,3 +40,92 @@ pub struct GeocoderCountryListResult { pub lat: f64, pub lng: f64, } + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeocoderLocalityCandidate { + pub id: i64, + pub name: String, + pub admin1_id: Option<i64>, + pub admin1_name: Option<String>, + pub country_id: String, + pub country_name: Option<String>, + pub point: GeocoderPoint, + pub display_name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GeocoderStructuredLocalityQuery { + pub locality: String, + pub region: Option<String>, + pub country: Option<String>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum GeocoderLocalityInput { + Structured(GeocoderStructuredLocalityQuery), + Query(String), + FeatureId(i64), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GeocoderLocalityQuery { + pub input: GeocoderLocalityInput, + pub limit: usize, +} + +impl GeocoderLocalityQuery { + pub fn structured(locality: impl Into<String>) -> Self { + Self { + input: GeocoderLocalityInput::Structured(GeocoderStructuredLocalityQuery { + locality: locality.into(), + region: None, + country: None, + }), + limit: 10, + } + } + + pub fn query(query: impl Into<String>) -> Self { + Self { + input: GeocoderLocalityInput::Query(query.into()), + limit: 10, + } + } + + pub fn feature_id(id: i64) -> Self { + Self { + input: GeocoderLocalityInput::FeatureId(id), + limit: 10, + } + } + + pub fn with_region(mut self, region: impl Into<String>) -> Self { + if let GeocoderLocalityInput::Structured(query) = &mut self.input { + query.region = Some(region.into()); + } + self + } + + pub fn with_country(mut self, country: impl Into<String>) -> Self { + if let GeocoderLocalityInput::Structured(query) = &mut self.input { + query.country = Some(country.into()); + } + self + } + + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = limit; + self + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum GeocoderLocalityLookup { + Unique { + candidate: GeocoderLocalityCandidate, + }, + NoMatch, + Ambiguous { + candidates: Vec<GeocoderLocalityCandidate>, + }, +} diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs @@ -1,5 +1,6 @@ use radroots_geocoder::{ - Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderPoint, GeocoderReverseOptions, + Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderLocalityLookup, + GeocoderLocalityQuery, GeocoderPoint, GeocoderReverseOptions, }; use rusqlite::Connection; use std::fs; @@ -75,6 +76,57 @@ fn reverse_orders_high_latitude_results_by_scaled_longitude_distance() { } #[test] +fn locality_resolves_structured_query_freeform_query_id_and_ambiguity() { + let geocoder = open_forward_fixture_geocoder(); + + let structured = geocoder + .locality( + &GeocoderLocalityQuery::structured("Fixture Victoria") + .with_region("BC") + .with_country("CA"), + ) + .expect("structured lookup"); + assert_unique_locality( + structured, + 3001, + "Fixture Victoria, British Columbia, Canada", + ); + + let freeform = geocoder + .locality(&GeocoderLocalityQuery::query("Fixture Victoria, BC, CA")) + .expect("freeform lookup"); + assert_unique_locality(freeform, 3001, "Fixture Victoria, British Columbia, Canada"); + + let feature_id = geocoder + .locality(&GeocoderLocalityQuery::feature_id(3004)) + .expect("feature-id lookup"); + assert_unique_locality( + feature_id, + 3004, + "Identifier Grove, British Columbia, Canada", + ); + + let ambiguous = geocoder + .locality(&GeocoderLocalityQuery::structured("Shared Market").with_country("CA")) + .expect("ambiguous lookup"); + let GeocoderLocalityLookup::Ambiguous { candidates } = ambiguous else { + panic!("expected ambiguous lookup"); + }; + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.id) + .collect::<Vec<_>>(), + vec![3002, 3003] + ); + + let no_match = geocoder + .locality(&GeocoderLocalityQuery::structured("Missing Market").with_country("CA")) + .expect("no-match lookup"); + assert!(matches!(no_match, GeocoderLocalityLookup::NoMatch)); +} + +#[test] fn open_bytes_supports_reverse_queries() { let path = build_fixture_database(); let bytes = fs::read(&path).expect("read fixture database bytes"); @@ -275,6 +327,11 @@ fn open_high_latitude_geocoder() -> Geocoder { Geocoder::open_path(&path).expect("open geocoder") } +fn open_forward_fixture_geocoder() -> Geocoder { + let path = build_forward_fixture_database(); + Geocoder::open_path(&path).expect("open geocoder") +} + fn open_empty_geocoder() -> Geocoder { let temp = NamedTempFile::new().expect("temp db"); let path = temp.into_temp_path(); @@ -309,6 +366,13 @@ fn build_high_latitude_database() -> tempfile::TempPath { path } +fn build_forward_fixture_database() -> tempfile::TempPath { + let temp = NamedTempFile::new().expect("temp db"); + let path = temp.into_temp_path(); + seed_forward_fixture_database(path.to_str().expect("utf-8 temp path")); + path +} + fn seed_fixture_database(path: &str) { let conn = Connection::open(path).expect("open fixture database"); seed_schema(&conn); @@ -337,6 +401,32 @@ fn seed_high_latitude_database(path: &str) { insert_feature(&conn, 2, "Polar North", "NO", 1, 75.05, 0.05); } +fn seed_forward_fixture_database(path: &str) { + let conn = Connection::open(path).expect("open fixture database"); + seed_schema(&conn); + + insert_country(&conn, "CA", "Canada"); + insert_country(&conn, "US", "United States"); + + insert_admin1(&conn, "CA", 2, "British Columbia"); + insert_admin1(&conn, "CA", 3, "Prairie Region"); + insert_admin1(&conn, "US", 4, "River Region"); + + insert_feature( + &conn, + 3001, + "Fixture Victoria", + "CA", + 2, + 48.4359, + -123.35155, + ); + insert_feature(&conn, 3002, "Shared Market", "CA", 2, 48.7, -123.2); + insert_feature(&conn, 3003, "Shared Market", "CA", 3, 50.2, -110.4); + insert_feature(&conn, 3004, "Identifier Grove", "CA", 2, 48.9, -123.4); + insert_feature(&conn, 3005, "Query Hamlet", "US", 4, 39.25, -77.5); +} + fn seed_reverse_country_row_error_database(path: &str) { let conn = Connection::open(path).expect("open invalid row fixture database"); conn.execute_batch( @@ -497,3 +587,15 @@ fn assert_country_center_not_found(err: GeocoderError, country_id: &str) { other => panic!("expected CountryCenterNotFound, got {other}"), } } + +fn assert_unique_locality( + lookup: GeocoderLocalityLookup, + expected_id: i64, + expected_display_name: &str, +) { + let GeocoderLocalityLookup::Unique { candidate } = lookup else { + panic!("expected unique lookup"); + }; + assert_eq!(candidate.id, expected_id); + assert_eq!(candidate.display_name, expected_display_name); +}