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