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:
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);
+}