lib

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

commit de373374ca5d2ef3408ba5f62e784bb754308065
parent d096d9e8e2ee058e75d0d6684240887da244c546
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 09:24:38 +0000

geonames: align package manifest and module root

- establish the exact five-module provider boundary
- define private validated asset and query models
- expose only the approved crate-root types
- keep construction free of hidden input and output

Diffstat:
Mcrates/geonames/Cargo.toml | 3++-
Acrates/geonames/src/asset.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/geonames/src/database.rs | 10++++++++++
Acrates/geonames/src/download.rs | 4++++
Acrates/geonames/src/error.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/geonames/src/lib.rs | 20+++++++++++++++++++-
Acrates/geonames/src/model.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/geonames/src/query.rs | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 606 insertions(+), 2 deletions(-)

diff --git a/crates/geonames/Cargo.toml b/crates/geonames/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radroots_geonames" -description = "GeoNames-backed geocoding for Radroots" +description = "Deterministic GeoNames asset and locality lookup provider for Radroots" version = "0.1.0-alpha" edition.workspace = true rust-version.workspace = true @@ -13,6 +13,7 @@ publish = false [lib] name = "radroots_geonames" +path = "src/lib.rs" [lints] workspace = true diff --git a/crates/geonames/src/asset.rs b/crates/geonames/src/asset.rs @@ -0,0 +1,153 @@ +//! Host-supplied GeoNames asset identity and passive status. + +use crate::Error; + +/// The expected identity of one immutable GeoNames database asset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetSpec { + version: String, + file_name: String, + source: String, + byte_size: u64, + sha256: [u8; 32], +} + +impl AssetSpec { + /// Creates an explicit asset specification without reading or downloading it. + pub fn new( + version: impl Into<String>, + file_name: impl Into<String>, + source: impl Into<String>, + byte_size: u64, + sha256: [u8; 32], + ) -> Result<Self, Error> { + let version = version.into(); + if !is_normalized_non_empty(&version) { + return Err(Error::InvalidAssetVersion); + } + + let file_name = file_name.into(); + if !is_safe_file_name(&file_name) { + return Err(Error::InvalidAssetFileName); + } + + let source = source.into(); + if !is_normalized_non_empty(&source) { + return Err(Error::InvalidAssetSource); + } + if byte_size == 0 { + return Err(Error::InvalidAssetByteSize); + } + + Ok(Self { + version, + file_name, + source, + byte_size, + sha256, + }) + } + + /// Returns the provider asset version. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Returns the expected destination file name. + #[must_use] + pub fn file_name(&self) -> &str { + &self.file_name + } + + /// Returns the host-supplied source identifier. + #[must_use] + pub fn source(&self) -> &str { + &self.source + } + + /// Returns the exact expected byte size. + #[must_use] + pub const fn byte_size(&self) -> u64 { + self.byte_size + } + + /// Returns the expected SHA-256 digest bytes. + #[must_use] + pub const fn sha256(&self) -> &[u8; 32] { + &self.sha256 + } +} + +/// Passive state of an explicitly inspected asset. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum AssetStatus { + /// No filesystem entry exists at the inspected path. + Missing, + /// The entry matches the complete [`AssetSpec`]. + Available, + /// The entry exists but does not match the specification. + Invalid, +} + +fn is_normalized_non_empty(value: &str) -> bool { + !value.is_empty() && value.trim() == value +} + +fn is_safe_file_name(value: &str) -> bool { + is_normalized_non_empty(value) && value != "." && value != ".." && !value.contains(['/', '\\']) +} + +#[cfg(test)] +mod tests { + use super::{AssetSpec, AssetStatus}; + use crate::Error; + + fn spec() -> AssetSpec { + AssetSpec::new( + "2026-08", + "geonames-2026-08.db", + "https://assets.example/geonames-2026-08.db", + 42, + [7; 32], + ) + .expect("valid asset specification") + } + + #[test] + fn asset_spec_preserves_explicit_identity() { + let spec = spec(); + assert_eq!(spec.version(), "2026-08"); + assert_eq!(spec.file_name(), "geonames-2026-08.db"); + assert_eq!(spec.source(), "https://assets.example/geonames-2026-08.db"); + assert_eq!(spec.byte_size(), 42); + assert_eq!(spec.sha256(), &[7; 32]); + } + + #[test] + fn asset_spec_rejects_ambient_or_unsafe_values() { + assert_eq!( + AssetSpec::new(" ", "asset.db", "source", 1, [0; 32]), + Err(Error::InvalidAssetVersion) + ); + assert_eq!( + AssetSpec::new("v1", "../asset.db", "source", 1, [0; 32]), + Err(Error::InvalidAssetFileName) + ); + assert_eq!( + AssetSpec::new("v1", "asset.db", " source", 1, [0; 32]), + Err(Error::InvalidAssetSource) + ); + assert_eq!( + AssetSpec::new("v1", "asset.db", "source", 0, [0; 32]), + Err(Error::InvalidAssetByteSize) + ); + } + + #[test] + fn asset_status_is_passive_and_exhaustive_for_v1() { + assert_ne!(AssetStatus::Missing, AssetStatus::Available); + assert_ne!(AssetStatus::Available, AssetStatus::Invalid); + } +} diff --git a/crates/geonames/src/database.rs b/crates/geonames/src/database.rs @@ -0,0 +1,10 @@ +//! Explicit GeoNames database lifecycle. + +/// An opened, verified GeoNames database. +/// +/// Construction is introduced with the explicit database lifecycle checkpoint; +/// this type performs no work merely by being linked or imported. +#[derive(Debug)] +pub struct Geocoder { + _private: (), +} diff --git a/crates/geonames/src/download.rs b/crates/geonames/src/download.rs @@ -0,0 +1,4 @@ +//! Explicit, caller-driven asset acquisition. +//! +//! Fetch and installation behavior is introduced by the acquisition +//! checkpoint. Importing this module never starts network or filesystem work. diff --git a/crates/geonames/src/error.rs b/crates/geonames/src/error.rs @@ -0,0 +1,42 @@ +use std::fmt; + +/// Failure returned by GeoNames configuration and lookup operations. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// An asset version was empty or contained surrounding whitespace. + InvalidAssetVersion, + /// An asset file name was not one safe, relative path component. + InvalidAssetFileName, + /// An asset source was empty or contained surrounding whitespace. + InvalidAssetSource, + /// An asset declared a zero byte size. + InvalidAssetByteSize, + /// A coordinate was non-finite or outside its geographic bounds. + InvalidPoint, + /// A query string was empty or contained surrounding whitespace. + InvalidQueryText, + /// A query limit was outside the supported range. + InvalidQueryLimit, + /// A locality-only option was applied to another query kind. + QueryOptionNotApplicable, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidAssetVersion => "asset version must be non-empty and normalized", + Self::InvalidAssetFileName => { + "asset file name must be one safe relative path component" + } + Self::InvalidAssetSource => "asset source must be non-empty and normalized", + Self::InvalidAssetByteSize => "asset byte size must be greater than zero", + Self::InvalidPoint => "point must contain finite, in-range coordinates", + Self::InvalidQueryText => "query text must be non-empty and normalized", + Self::InvalidQueryLimit => "query limit must be between 1 and 100", + Self::QueryOptionNotApplicable => "query option is not applicable to this query kind", + }) + } +} + +impl std::error::Error for Error {} diff --git a/crates/geonames/src/lib.rs b/crates/geonames/src/lib.rs @@ -1 +1,19 @@ -//! GeoNames-backed geocoding for Radroots. +//! Deterministic GeoNames asset management and locality lookup. +//! +//! This crate never chooses runtime paths or performs work during +//! construction. Hosts explicitly provide every asset source and destination. + +#![forbid(unsafe_code)] + +pub mod asset; +pub mod database; +pub mod download; +mod error; +pub mod model; +pub mod query; + +pub use asset::{AssetSpec, AssetStatus}; +pub use database::Geocoder; +pub use error::Error; +pub use model::{Candidate, Point}; +pub use query::Query; diff --git a/crates/geonames/src/model.rs b/crates/geonames/src/model.rs @@ -0,0 +1,155 @@ +//! Provider-owned locality result models. + +use crate::Error; + +/// A geographic point in decimal degrees. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Point { + latitude: f64, + longitude: f64, +} + +impl Point { + /// Creates a finite point within the WGS84 latitude/longitude bounds. + pub fn new(latitude: f64, longitude: f64) -> Result<Self, Error> { + if !latitude.is_finite() + || !longitude.is_finite() + || !(-90.0..=90.0).contains(&latitude) + || !(-180.0..=180.0).contains(&longitude) + { + return Err(Error::InvalidPoint); + } + Ok(Self { + latitude, + longitude, + }) + } + + /// Returns the latitude in decimal degrees. + #[must_use] + pub const fn latitude(self) -> f64 { + self.latitude + } + + /// Returns the longitude in decimal degrees. + #[must_use] + pub const fn longitude(self) -> f64 { + self.longitude + } +} + +/// One deterministic locality candidate returned by GeoNames. +#[derive(Clone, Debug, PartialEq)] +pub struct Candidate { + feature_id: u64, + name: String, + admin1_id: Option<String>, + admin1_name: Option<String>, + country_id: String, + country_name: Option<String>, + point: Point, + display_name: String, +} + +impl Candidate { + /// Returns the stable GeoNames feature identifier. + #[must_use] + pub const fn feature_id(&self) -> u64 { + self.feature_id + } + + /// Returns the canonical locality name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the opaque first-level administrative identifier. + #[must_use] + pub fn admin1_id(&self) -> Option<&str> { + self.admin1_id.as_deref() + } + + /// Returns the first-level administrative name when present. + #[must_use] + pub fn admin1_name(&self) -> Option<&str> { + self.admin1_name.as_deref() + } + + /// Returns the ISO-like country identifier stored by the asset. + #[must_use] + pub fn country_id(&self) -> &str { + &self.country_id + } + + /// Returns the country name when present. + #[must_use] + pub fn country_name(&self) -> Option<&str> { + self.country_name.as_deref() + } + + /// Returns the candidate coordinate. + #[must_use] + pub const fn point(&self) -> Point { + self.point + } + + /// Returns the deterministic human-readable label. + #[must_use] + pub fn display_name(&self) -> &str { + &self.display_name + } +} + +#[cfg(test)] +mod tests { + use super::{Candidate, Point}; + use crate::Error; + + #[test] + fn points_enforce_finite_geographic_bounds() { + assert_eq!( + Point::new(48.4284, -123.3656), + Ok(Point { + latitude: 48.4284, + longitude: -123.3656, + }) + ); + for (latitude, longitude) in [ + (f64::NAN, 0.0), + (0.0, f64::INFINITY), + (-90.1, 0.0), + (90.1, 0.0), + (0.0, -180.1), + (0.0, 180.1), + ] { + assert_eq!(Point::new(latitude, longitude), Err(Error::InvalidPoint)); + } + } + + #[test] + fn candidates_expose_provider_values_without_public_fields() { + let point = Point::new(48.4284, -123.3656).expect("valid point"); + let candidate = Candidate { + feature_id: 617_4041, + name: "Victoria".to_owned(), + admin1_id: Some("BC".to_owned()), + admin1_name: Some("British Columbia".to_owned()), + country_id: "CA".to_owned(), + country_name: Some("Canada".to_owned()), + point, + display_name: "Victoria, British Columbia, Canada".to_owned(), + }; + assert_eq!(candidate.feature_id(), 617_4041); + assert_eq!(candidate.name(), "Victoria"); + assert_eq!(candidate.admin1_id(), Some("BC")); + assert_eq!(candidate.admin1_name(), Some("British Columbia")); + assert_eq!(candidate.country_id(), "CA"); + assert_eq!(candidate.country_name(), Some("Canada")); + assert_eq!(candidate.point(), point); + assert_eq!( + candidate.display_name(), + "Victoria, British Columbia, Canada" + ); + } +} diff --git a/crates/geonames/src/query.rs b/crates/geonames/src/query.rs @@ -0,0 +1,221 @@ +//! Validated forward and reverse locality queries. + +use crate::{Error, Point}; + +const DEFAULT_LIMIT: usize = 10; +const MAX_LIMIT: usize = 100; + +/// A validated GeoNames lookup request. +#[derive(Clone, Debug, PartialEq)] +pub struct Query { + pub(crate) kind: QueryKind, + limit: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum QueryKind { + Locality { + locality: String, + region: Option<String>, + country: Option<String>, + }, + Freeform(String), + FeatureId(u64), + Reverse(Point), + Countries, +} + +impl Query { + /// Creates a structured locality query. + pub fn locality(locality: impl Into<String>) -> Result<Self, Error> { + Ok(Self { + kind: QueryKind::Locality { + locality: normalized_query_text(locality)?, + region: None, + country: None, + }, + limit: DEFAULT_LIMIT, + }) + } + + /// Creates a free-form locality query. + pub fn freeform(query: impl Into<String>) -> Result<Self, Error> { + Ok(Self { + kind: QueryKind::Freeform(normalized_query_text(query)?), + limit: DEFAULT_LIMIT, + }) + } + + /// Creates an exact GeoNames feature query. + #[must_use] + pub const fn feature_id(feature_id: u64) -> Self { + Self { + kind: QueryKind::FeatureId(feature_id), + limit: 1, + } + } + + /// Creates a nearest-locality query around an explicit point. + #[must_use] + pub const fn reverse(point: Point) -> Self { + Self { + kind: QueryKind::Reverse(point), + limit: 1, + } + } + + /// Creates a deterministic country-list query. + #[must_use] + pub const fn countries() -> Self { + Self { + kind: QueryKind::Countries, + limit: DEFAULT_LIMIT, + } + } + + /// Narrows a structured locality query by administrative region. + pub fn with_region(mut self, region: impl Into<String>) -> Result<Self, Error> { + let QueryKind::Locality { + region: current, .. + } = &mut self.kind + else { + return Err(Error::QueryOptionNotApplicable); + }; + *current = Some(normalized_query_text(region)?); + Ok(self) + } + + /// Narrows a structured locality query by country identifier or name. + pub fn with_country(mut self, country: impl Into<String>) -> Result<Self, Error> { + let QueryKind::Locality { + country: current, .. + } = &mut self.kind + else { + return Err(Error::QueryOptionNotApplicable); + }; + *current = Some(normalized_query_text(country)?); + Ok(self) + } + + /// Sets the maximum result count. + pub fn with_limit(mut self, limit: usize) -> Result<Self, Error> { + if !(1..=MAX_LIMIT).contains(&limit) { + return Err(Error::InvalidQueryLimit); + } + self.limit = limit; + Ok(self) + } + + /// Returns the maximum result count. + #[must_use] + pub const fn limit(&self) -> usize { + self.limit + } + + /// Returns structured locality fields when this is a locality query. + #[must_use] + pub fn locality_fields(&self) -> Option<(&str, Option<&str>, Option<&str>)> { + match &self.kind { + QueryKind::Locality { + locality, + region, + country, + } => Some((locality, region.as_deref(), country.as_deref())), + _ => None, + } + } + + /// Returns free-form text when this is a free-form query. + #[must_use] + pub fn freeform_text(&self) -> Option<&str> { + match &self.kind { + QueryKind::Freeform(value) => Some(value), + _ => None, + } + } + + /// Returns the feature identifier when this is an exact feature query. + #[must_use] + pub fn exact_feature_id(&self) -> Option<u64> { + match &self.kind { + QueryKind::FeatureId(value) => Some(*value), + _ => None, + } + } + + /// Returns the point when this is a reverse query. + #[must_use] + pub fn reverse_point(&self) -> Option<Point> { + match &self.kind { + QueryKind::Reverse(value) => Some(*value), + _ => None, + } + } + + /// Returns whether this query requests the country list. + #[must_use] + pub fn is_country_list(&self) -> bool { + matches!(&self.kind, QueryKind::Countries) + } +} + +fn normalized_query_text(value: impl Into<String>) -> Result<String, Error> { + let value = value.into(); + if value.is_empty() || value.trim() != value { + return Err(Error::InvalidQueryText); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::{Query, QueryKind}; + use crate::{Error, Point}; + + #[test] + fn structured_queries_keep_normalized_filters_private() { + let query = Query::locality("Victoria") + .expect("locality") + .with_region("British Columbia") + .expect("region") + .with_country("CA") + .expect("country") + .with_limit(7) + .expect("limit"); + assert_eq!(query.limit(), 7); + assert_eq!( + query.kind, + QueryKind::Locality { + locality: "Victoria".to_owned(), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + } + ); + } + + #[test] + fn query_constructors_reject_ambiguous_or_unbounded_input() { + assert_eq!(Query::locality(""), Err(Error::InvalidQueryText)); + assert_eq!(Query::freeform(" Victoria "), Err(Error::InvalidQueryText)); + assert_eq!( + Query::feature_id(1).with_country("CA"), + Err(Error::QueryOptionNotApplicable) + ); + assert_eq!( + Query::countries().with_limit(0), + Err(Error::InvalidQueryLimit) + ); + assert_eq!( + Query::countries().with_limit(101), + Err(Error::InvalidQueryLimit) + ); + } + + #[test] + fn exact_reverse_and_country_queries_have_bounded_defaults() { + let point = Point::new(48.4284, -123.3656).expect("point"); + assert_eq!(Query::feature_id(617_4041).limit(), 1); + assert_eq!(Query::reverse(point).limit(), 1); + assert_eq!(Query::countries().limit(), 10); + } +}