commit 3d795866d1da9333b5bee3f740c9675d7e89645e
parent 52b4a3028d5559848de5720988b3f2dd4d0ebbdb
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 21:01:24 +0000
geocoder: add offline geonames queries
Diffstat:
11 files changed, 550 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2151,6 +2151,16 @@ dependencies = [
]
[[package]]
+name = "radroots-geocoder"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "rusqlite",
+ "serde",
+ "tempfile",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "radroots-identity"
version = "0.1.0-alpha.1"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"crates/events-codec",
"crates/events-codec-wasm",
"crates/events-indexed",
+ "crates/geocoder",
"crates/identity",
"crates/log",
"crates/net",
@@ -44,6 +45,7 @@ radroots-core = { path = "crates/core", version = "0.1.0-alpha.1", default-featu
radroots-events = { path = "crates/events", version = "0.1.0-alpha.1", default-features = false }
radroots-events-codec = { path = "crates/events-codec", version = "0.1.0-alpha.1", default-features = false }
radroots-events-indexed = { path = "crates/events-indexed", version = "0.1.0-alpha.1", default-features = false }
+radroots-geocoder = { path = "crates/geocoder", version = "0.1.0-alpha.1" }
radroots-identity = { path = "crates/identity", version = "0.1.0-alpha.1", default-features = false }
radroots-nostr = { path = "crates/nostr", version = "0.1.0-alpha.1", default-features = false }
radroots-nostr-accounts = { path = "crates/nostr-accounts", version = "0.1.0-alpha.1", default-features = false }
diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml
@@ -17,6 +17,7 @@ crates = [
"radroots-replica-db-schema",
"xtask",
"radroots-events-indexed",
+ "radroots-geocoder",
"radroots-log",
"radroots-net-core",
"radroots-net",
diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml
@@ -13,6 +13,7 @@ crates = [
"radroots-events-codec",
"radroots-events-codec-wasm",
"radroots-events-indexed",
+ "radroots-geocoder",
"radroots-nostr",
"radroots-nostr-connect",
"radroots-nostr-signer",
@@ -46,6 +47,7 @@ crates = [
"radroots-trade",
"radroots-events-codec-wasm",
"radroots-events-indexed",
+ "radroots-geocoder",
"radroots-nostr",
"radroots-nostr-connect",
"radroots-nostr-signer",
diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "radroots-geocoder"
+version = "0.1.0-alpha.1"
+edition.workspace = true
+authors = [
+ "Radroots Authors",
+]
+rust-version.workspace = true
+license.workspace = true
+description = "offline geocoder queries for radroots geonames datasets"
+repository.workspace = true
+homepage.workspace = true
+documentation = "https://docs.rs/radroots-geocoder"
+readme.workspace = true
+
+[dependencies]
+rusqlite = { workspace = true, features = ["bundled", "serialize"] }
+serde = { workspace = true, features = ["derive"] }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/geocoder/README.md b/crates/geocoder/README.md
@@ -0,0 +1,3 @@
+# radroots-geocoder
+
+Offline geocoder queries for the Rad Roots `geonames.db` dataset.
diff --git a/crates/geocoder/src/error.rs b/crates/geocoder/src/error.rs
@@ -0,0 +1,9 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum GeocoderError {
+ #[error("sqlite error: {0}")]
+ Sqlite(#[from] rusqlite::Error),
+ #[error("country center not found for {country_id}")]
+ CountryCenterNotFound { country_id: String },
+}
diff --git a/crates/geocoder/src/geocoder.rs b/crates/geocoder/src/geocoder.rs
@@ -0,0 +1,159 @@
+use crate::error::GeocoderError;
+use crate::model::{
+ GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult,
+};
+use rusqlite::{Connection, MAIN_DB, named_params};
+use std::path::Path;
+
+pub struct Geocoder {
+ conn: Connection,
+}
+
+impl Geocoder {
+ pub fn open_path<P: AsRef<Path>>(path: P) -> Result<Self, GeocoderError> {
+ let conn = Connection::open(path)?;
+ Ok(Self { conn })
+ }
+
+ pub fn open_bytes(bytes: &[u8]) -> Result<Self, GeocoderError> {
+ let mut conn = Connection::open_in_memory()?;
+ conn.deserialize_read_exact(MAIN_DB, bytes, bytes.len(), true)?;
+ Ok(Self { conn })
+ }
+
+ pub fn reverse(
+ &self,
+ point: GeocoderPoint,
+ options: Option<GeocoderReverseOptions>,
+ ) -> Result<Vec<GeocoderReverseResult>, GeocoderError> {
+ let options = options.unwrap_or_default();
+ let lng_weight = point.lat.to_radians().cos().powi(2);
+ let mut stmt = self.conn.prepare(
+ r#"
+ SELECT
+ g.id,
+ g.name,
+ g.admin1_id,
+ g.admin1_name,
+ g.country_id,
+ g.country_name,
+ g.latitude,
+ g.longitude
+ FROM geonames AS g
+ JOIN coordinates AS c
+ ON g.id = c.feature_id
+ WHERE c.latitude BETWEEN :lat - :degree_offset AND :lat + :degree_offset
+ AND c.longitude BETWEEN :lng - :degree_offset AND :lng + :degree_offset
+ ORDER BY
+ ((:lat - c.latitude) * (:lat - c.latitude))
+ + ((:lng - c.longitude) * (:lng - c.longitude) * :lng_weight) ASC
+ LIMIT :limit
+ "#,
+ )?;
+ let rows = stmt.query_map(
+ named_params! {
+ ":lat": point.lat,
+ ":lng": point.lng,
+ ":degree_offset": options.degree_offset,
+ ":lng_weight": lng_weight,
+ ":limit": options.limit as i64,
+ },
+ map_reverse_row,
+ )?;
+ rows.collect::<Result<Vec<_>, _>>()
+ .map_err(GeocoderError::from)
+ }
+
+ pub fn country(&self, country_id: &str) -> Result<Vec<GeocoderReverseResult>, GeocoderError> {
+ let mut stmt = self.conn.prepare(
+ r#"
+ SELECT
+ id,
+ name,
+ admin1_id,
+ admin1_name,
+ country_id,
+ country_name,
+ latitude,
+ longitude
+ FROM geonames
+ WHERE country_id = :country_id
+ ORDER BY id ASC
+ "#,
+ )?;
+ let rows = stmt.query_map(
+ named_params! {
+ ":country_id": country_id,
+ },
+ map_reverse_row,
+ )?;
+ rows.collect::<Result<Vec<_>, _>>()
+ .map_err(GeocoderError::from)
+ }
+
+ pub fn country_list(&self) -> Result<Vec<GeocoderCountryListResult>, GeocoderError> {
+ let mut stmt = self.conn.prepare(
+ r#"
+ SELECT
+ country_id,
+ country_name,
+ AVG(latitude) AS latitude_c,
+ AVG(longitude) AS longitude_c
+ FROM geonames
+ GROUP BY country_id, country_name
+ ORDER BY country_id ASC
+ "#,
+ )?;
+ let rows = stmt.query_map([], |row| {
+ Ok(GeocoderCountryListResult {
+ country_id: row.get("country_id")?,
+ country: row.get("country_name")?,
+ lat: row.get("latitude_c")?,
+ lng: row.get("longitude_c")?,
+ })
+ })?;
+ rows.collect::<Result<Vec<_>, _>>()
+ .map_err(GeocoderError::from)
+ }
+
+ pub fn country_center(&self, country_id: &str) -> Result<GeocoderPoint, GeocoderError> {
+ let mut stmt = self.conn.prepare(
+ r#"
+ SELECT
+ AVG(latitude) AS latitude_c,
+ AVG(longitude) AS longitude_c
+ FROM geonames
+ WHERE country_id = :country_id
+ "#,
+ )?;
+ let mut rows = stmt.query(named_params! {
+ ":country_id": country_id,
+ })?;
+ let Some(row) = rows.next()? else {
+ return Err(GeocoderError::CountryCenterNotFound {
+ country_id: country_id.to_owned(),
+ });
+ };
+ let lat: Option<f64> = row.get("latitude_c")?;
+ let lng: Option<f64> = row.get("longitude_c")?;
+ match (lat, lng) {
+ (Some(lat), Some(lng)) => Ok(GeocoderPoint { lat, lng }),
+ _ => Err(GeocoderError::CountryCenterNotFound {
+ country_id: country_id.to_owned(),
+ }),
+ }
+ }
+}
+
+fn map_reverse_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GeocoderReverseResult> {
+ Ok(GeocoderReverseResult {
+ id: row.get("id")?,
+ name: row.get("name")?,
+ admin1_id: row.get("admin1_id")?,
+ admin1_name: row.get("admin1_name")?,
+ country_id: row.get("country_id")?,
+ country_name: row.get("country_name")?,
+ latitude: row.get("latitude")?,
+ longitude: row.get("longitude")?,
+ })
+}
diff --git a/crates/geocoder/src/lib.rs b/crates/geocoder/src/lib.rs
@@ -0,0 +1,11 @@
+#![forbid(unsafe_code)]
+
+mod error;
+mod geocoder;
+mod model;
+
+pub use error::GeocoderError;
+pub use geocoder::Geocoder;
+pub use model::{
+ GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult,
+};
diff --git a/crates/geocoder/src/model.rs b/crates/geocoder/src/model.rs
@@ -0,0 +1,42 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct GeocoderPoint {
+ pub lat: f64,
+ pub lng: f64,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct GeocoderReverseOptions {
+ pub limit: usize,
+ pub degree_offset: f64,
+}
+
+impl Default for GeocoderReverseOptions {
+ fn default() -> Self {
+ Self {
+ limit: 1,
+ degree_offset: 0.5,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct GeocoderReverseResult {
+ 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 latitude: f64,
+ pub longitude: f64,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct GeocoderCountryListResult {
+ pub country_id: String,
+ pub country: Option<String>,
+ pub lat: f64,
+ pub lng: f64,
+}
diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs
@@ -0,0 +1,286 @@
+use radroots_geocoder::{
+ Geocoder, GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions,
+};
+use rusqlite::Connection;
+use std::fs;
+use tempfile::NamedTempFile;
+
+#[test]
+fn reverse_returns_nearest_match_by_default() {
+ let geocoder = open_fixture_geocoder();
+
+ let results = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 37.7749,
+ lng: -122.4194,
+ },
+ None,
+ )
+ .expect("reverse query");
+
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].id, 1);
+ assert_eq!(results[0].name, "San Francisco");
+ assert_eq!(results[0].country_id, "US");
+ assert_eq!(results[0].admin1_id, Some(6));
+ assert_eq!(results[0].admin1_name.as_deref(), Some("California"));
+}
+
+#[test]
+fn reverse_respects_limit_and_returns_sorted_matches() {
+ let geocoder = open_fixture_geocoder();
+
+ let results = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 37.7749,
+ lng: -122.4194,
+ },
+ Some(GeocoderReverseOptions {
+ limit: 2,
+ degree_offset: 10.0,
+ }),
+ )
+ .expect("reverse query");
+
+ assert_eq!(results.len(), 2);
+ assert_eq!(results[0].id, 1);
+ assert_eq!(results[1].id, 2);
+}
+
+#[test]
+fn reverse_orders_high_latitude_results_by_scaled_longitude_distance() {
+ let geocoder = open_high_latitude_geocoder();
+
+ let results = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 75.0,
+ lng: 0.0,
+ },
+ Some(GeocoderReverseOptions {
+ limit: 2,
+ degree_offset: 1.0,
+ }),
+ )
+ .expect("reverse query");
+
+ assert_eq!(results.len(), 2);
+ assert_eq!(results[0].id, 1);
+ assert_eq!(results[0].name, "Polar East");
+ assert_eq!(results[1].id, 2);
+ assert_eq!(results[1].name, "Polar North");
+}
+
+#[test]
+fn open_bytes_supports_reverse_queries() {
+ let path = build_fixture_database();
+ let bytes = fs::read(&path).expect("read fixture database bytes");
+ let geocoder = Geocoder::open_bytes(&bytes).expect("open byte-backed geocoder");
+
+ let results = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 34.0522,
+ lng: -118.2437,
+ },
+ None,
+ )
+ .expect("reverse query");
+
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].id, 2);
+ assert_eq!(results[0].name, "Los Angeles");
+}
+
+#[test]
+fn country_returns_all_rows_for_requested_country() {
+ let geocoder = open_fixture_geocoder();
+
+ let results = geocoder.country("US").expect("country query");
+
+ assert_eq!(results.len(), 3);
+ assert!(results.iter().all(|result| result.country_id == "US"));
+}
+
+#[test]
+fn country_list_returns_average_centers() {
+ let geocoder = open_fixture_geocoder();
+
+ let results = geocoder.country_list().expect("country list query");
+
+ assert_eq!(results.len(), 2);
+ assert_eq!(
+ results[0],
+ GeocoderCountryListResult {
+ country_id: "BR".to_owned(),
+ country: Some("Brazil".to_owned()),
+ lat: -23.5505,
+ lng: -46.6333,
+ }
+ );
+ assert_eq!(results[1].country_id, "US");
+ assert_eq!(results[1].country.as_deref(), Some("United States"));
+ assert!(approx_eq(
+ results[1].lat,
+ (37.7749 + 34.0522 + 40.7128) / 3.0
+ ));
+ assert!(approx_eq(
+ results[1].lng,
+ (-122.4194 + -118.2437 + -74.0060) / 3.0
+ ));
+}
+
+#[test]
+fn country_center_returns_average_for_country() {
+ let geocoder = open_fixture_geocoder();
+
+ let result = geocoder.country_center("US").expect("country center query");
+
+ assert!(approx_eq(result.lat, (37.7749 + 34.0522 + 40.7128) / 3.0));
+ assert!(approx_eq(
+ result.lng,
+ (-122.4194 + -118.2437 + -74.0060) / 3.0
+ ));
+}
+
+fn open_fixture_geocoder() -> Geocoder {
+ let path = build_fixture_database();
+ Geocoder::open_path(&path).expect("open geocoder")
+}
+
+fn open_high_latitude_geocoder() -> Geocoder {
+ let path = build_high_latitude_database();
+ Geocoder::open_path(&path).expect("open geocoder")
+}
+
+fn build_fixture_database() -> tempfile::TempPath {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_fixture_database(path.to_str().expect("utf-8 temp path"));
+ path
+}
+
+fn build_high_latitude_database() -> tempfile::TempPath {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_high_latitude_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);
+
+ insert_country(&conn, "US", "United States");
+ insert_country(&conn, "BR", "Brazil");
+
+ insert_admin1(&conn, "US", 6, "California");
+ insert_admin1(&conn, "US", 36, "New York");
+ insert_admin1(&conn, "BR", 27, "Sao Paulo");
+
+ insert_feature(&conn, 1, "San Francisco", "US", 6, 37.7749, -122.4194);
+ insert_feature(&conn, 2, "Los Angeles", "US", 6, 34.0522, -118.2437);
+ insert_feature(&conn, 3, "New York City", "US", 36, 40.7128, -74.0060);
+ insert_feature(&conn, 4, "Sao Paulo", "BR", 27, -23.5505, -46.6333);
+}
+
+fn seed_high_latitude_database(path: &str) {
+ let conn = Connection::open(path).expect("open fixture database");
+ seed_schema(&conn);
+
+ insert_country(&conn, "NO", "Norway");
+ insert_admin1(&conn, "NO", 1, "Nord");
+
+ insert_feature(&conn, 1, "Polar East", "NO", 1, 75.02, 0.10);
+ insert_feature(&conn, 2, "Polar North", "NO", 1, 75.05, 0.05);
+}
+
+fn seed_schema(conn: &Connection) {
+ conn.execute_batch(
+ r#"
+ CREATE TABLE countries(
+ id TEXT,
+ name TEXT,
+ PRIMARY KEY (id)
+ );
+ CREATE TABLE admin1(
+ country_id TEXT,
+ id INTEGER,
+ name TEXT,
+ PRIMARY KEY (country_id, id)
+ );
+ CREATE TABLE features(
+ id INTEGER,
+ name TEXT,
+ country_id TEXT,
+ admin1_id INTEGER,
+ PRIMARY KEY (id)
+ );
+ CREATE TABLE coordinates(
+ feature_id INTEGER,
+ latitude REAL,
+ longitude REAL,
+ PRIMARY KEY (feature_id)
+ );
+ CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude);
+ CREATE VIEW geonames AS
+ SELECT
+ features.id,
+ features.name,
+ admin1.id AS admin1_id,
+ admin1.name AS admin1_name,
+ countries.id AS country_id,
+ countries.name AS country_name,
+ coordinates.latitude AS latitude,
+ coordinates.longitude AS longitude
+ FROM features
+ LEFT JOIN countries ON features.country_id = countries.id
+ LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id
+ JOIN coordinates ON features.id = coordinates.feature_id;
+ "#,
+ )
+ .expect("create fixture schema");
+}
+
+fn insert_country(conn: &Connection, id: &str, name: &str) {
+ conn.execute(
+ "INSERT INTO countries (id, name) VALUES (?1, ?2)",
+ (id, name),
+ )
+ .expect("insert country");
+}
+
+fn insert_admin1(conn: &Connection, country_id: &str, id: i64, name: &str) {
+ conn.execute(
+ "INSERT INTO admin1 (country_id, id, name) VALUES (?1, ?2, ?3)",
+ (country_id, id, name),
+ )
+ .expect("insert admin1");
+}
+
+fn insert_feature(
+ conn: &Connection,
+ id: i64,
+ name: &str,
+ country_id: &str,
+ admin1_id: i64,
+ latitude: f64,
+ longitude: f64,
+) {
+ conn.execute(
+ "INSERT INTO features (id, name, country_id, admin1_id) VALUES (?1, ?2, ?3, ?4)",
+ (id, name, country_id, admin1_id),
+ )
+ .expect("insert feature");
+ conn.execute(
+ "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)",
+ (id, latitude, longitude),
+ )
+ .expect("insert coordinate");
+}
+
+fn approx_eq(left: f64, right: f64) -> bool {
+ (left - right).abs() < 0.000_001
+}