use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result};
pub type Mcc = u16;
pub type Mnc = u16;
pub type Lac = u32;
pub type CellId = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]
pub enum Radio {
Gsm,
Umts,
Lte,
#[serde(rename = "NBIOT")]
NbIot,
Nr,
Cdma,
}
impl Radio {
pub fn as_api_str(self) -> &'static str {
match self {
Self::Gsm => "GSM",
Self::Umts => "UMTS",
Self::Lte => "LTE",
Self::NbIot => "NBIOT",
Self::Nr => "NR",
Self::Cdma => "CDMA",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct CellKey {
pub mcc: Mcc,
pub mnc: Mnc,
pub lac: Lac,
pub cell_id: CellId,
pub radio: Option<Radio>,
}
impl CellKey {
pub fn new(mcc: Mcc, mnc: Mnc, lac: Lac, cell_id: CellId) -> Self {
Self { mcc, mnc, lac, cell_id, radio: None }
}
pub fn with_radio(mut self, radio: Radio) -> Self {
self.radio = Some(radio);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bbox {
lat_min: f64,
lon_min: f64,
lat_max: f64,
lon_max: f64,
}
impl Bbox {
pub fn new(lat_min: f64, lon_min: f64, lat_max: f64, lon_max: f64) -> Result<Self> {
if !lat_min.is_finite() || !lat_max.is_finite() || !lon_min.is_finite() || !lon_max.is_finite() {
return Err(Error::InvalidInput("bbox coordinates must be finite".into()));
}
if !(-90.0..=90.0).contains(&lat_min) || !(-90.0..=90.0).contains(&lat_max) {
return Err(Error::InvalidInput("latitude out of range".into()));
}
if !(-180.0..=180.0).contains(&lon_min) || !(-180.0..=180.0).contains(&lon_max) {
return Err(Error::InvalidInput("longitude out of range".into()));
}
if lat_min >= lat_max || lon_min >= lon_max {
return Err(Error::InvalidInput(
"min coordinates must be strictly less than max".into(),
));
}
Ok(Self { lat_min, lon_min, lat_max, lon_max })
}
pub fn lat_min(&self) -> f64 { self.lat_min }
pub fn lon_min(&self) -> f64 { self.lon_min }
pub fn lat_max(&self) -> f64 { self.lat_max }
pub fn lon_max(&self) -> f64 { self.lon_max }
pub fn to_query_value(self) -> String {
format!(
"{},{},{},{}",
format_coordinate(self.lat_min),
format_coordinate(self.lon_min),
format_coordinate(self.lat_max),
format_coordinate(self.lon_max),
)
}
}
pub(crate) fn format_coordinate(v: f64) -> String {
let mut s = format!("{v:.7}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s
}
fn deserialize_bool_from_int<'de, D>(d: D) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error as _;
let v = serde_json::Value::deserialize(d)?;
match v {
serde_json::Value::Bool(b) => Ok(b),
serde_json::Value::Number(n) => match n.as_u64() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err(D::Error::custom("expected 0 or 1 for boolean field")),
},
serde_json::Value::String(s) => match s.as_str() {
"0" => Ok(false),
"1" => Ok(true),
other => Err(D::Error::custom(format!("expected 0/1, got {other:?}"))),
},
other => Err(D::Error::custom(format!("expected boolean, got {other}"))),
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Cell {
pub lat: f64,
pub lon: f64,
pub mcc: Mcc,
pub mnc: Mnc,
pub lac: Lac,
#[serde(rename = "cellid", alias = "cellId")]
pub cell_id: CellId,
#[serde(default)]
pub range: u32,
#[serde(default)]
pub samples: u32,
#[serde(default, deserialize_with = "deserialize_bool_from_int")]
pub changeable: bool,
#[serde(rename = "averageSignalStrength", default)]
pub avg_signal: i32,
#[serde(default)]
pub radio: Option<Radio>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct CellCount {
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct Measurement {
pub lat: f64,
pub lon: f64,
pub mcc: Mcc,
pub mnc: Mnc,
pub lac: Lac,
#[serde(rename = "cellid")]
pub cell_id: CellId,
#[serde(rename = "act", serialize_with = "serialize_radio_as_str")]
pub radio: Radio,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub measured_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ta: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub psc: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tac: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pci: Option<u16>,
}
fn serialize_radio_as_str<S>(r: &Radio, s: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
s.serialize_str(r.as_api_str())
}
impl Measurement {
pub fn new(
lat: f64,
lon: f64,
mcc: Mcc,
mnc: Mnc,
lac: Lac,
cell_id: CellId,
radio: Radio,
) -> Result<Self> {
if !lat.is_finite() || !lon.is_finite() {
return Err(Error::InvalidInput("lat/lon must be finite".into()));
}
Ok(Self {
lat, lon, mcc, mnc, lac, cell_id, radio,
signal: None, measured_at: None, rating: None, speed: None,
direction: None, ta: None, psc: None, tac: None, pci: None,
})
}
pub fn with_signal(mut self, signal: i32) -> Self {
self.signal = Some(signal);
self
}
pub fn with_measured_at(mut self, ts: impl Into<String>) -> Self {
self.measured_at = Some(ts.into());
self
}
pub fn with_rating(mut self, rating: u32) -> Self {
self.rating = Some(rating);
self
}
pub fn with_speed(mut self, speed: f32) -> Self {
self.speed = Some(speed);
self
}
pub fn with_direction(mut self, direction: f32) -> Self {
self.direction = Some(direction);
self
}
pub fn validate(&self) -> Result<()> {
for (name, v) in [("speed", self.speed), ("direction", self.direction)] {
if let Some(v) = v {
if !v.is_finite() {
return Err(Error::InvalidInput(format!("{name} must be finite")));
}
}
}
Ok(())
}
pub fn with_ta(mut self, ta: u32) -> Self {
self.ta = Some(ta);
self
}
pub fn with_psc(mut self, psc: u16) -> Self {
self.psc = Some(psc);
self
}
pub fn with_tac(mut self, tac: u32) -> Self {
self.tac = Some(tac);
self
}
pub fn with_pci(mut self, pci: u16) -> Self {
self.pci = Some(pci);
self
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[non_exhaustive]
pub struct MeasurementsPayload {
pub measurements: Vec<Measurement>,
}
impl MeasurementsPayload {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DumpKind {
World,
Country(Mcc),
Daily {
date_utc: String,
},
}
impl DumpKind {
pub fn daily(date_utc: impl Into<String>) -> Result<Self> {
let date_utc = date_utc.into();
if !is_iso_date(&date_utc) {
return Err(Error::InvalidInput(format!(
"expected YYYY-MM-DD UTC date, got {date_utc:?}"
)));
}
Ok(Self::Daily { date_utc })
}
pub fn type_and_file(&self) -> Result<(&'static str, String)> {
match self {
Self::World => Ok(("full", "cell_towers.csv.gz".to_string())),
Self::Country(mcc) => {
if !(200..=999).contains(mcc) {
return Err(Error::InvalidInput(format!(
"MCC {mcc} is outside the ITU range 200..=999"
)));
}
Ok(("mcc", format!("{mcc}.csv.gz")))
}
Self::Daily { date_utc } => {
if !is_iso_date(date_utc) {
return Err(Error::InvalidInput(format!(
"expected YYYY-MM-DD UTC date, got {date_utc:?}"
)));
}
Ok((
"diff",
format!("OCID-diff-cell-export-{date_utc}-T000000.csv.gz"),
))
}
}
}
}
pub(crate) fn is_iso_date(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.len() == 10
&& bytes
.iter()
.enumerate()
.all(|(i, b)| if i == 4 || i == 7 { *b == b'-' } else { b.is_ascii_digit() })
}
#[cfg(feature = "csv")]
#[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
#[non_exhaustive]
pub struct DumpRow {
pub radio: Radio,
pub mcc: Mcc,
#[serde(rename = "net")]
pub mnc: Mnc,
#[serde(rename = "area")]
pub lac: Lac,
#[serde(rename = "cell")]
pub cell_id: CellId,
#[serde(default, deserialize_with = "deserialize_optional_u16")]
pub unit: Option<u16>,
pub lon: f64,
pub lat: f64,
pub range: u32,
pub samples: u32,
#[serde(deserialize_with = "deserialize_bool_from_int")]
pub changeable: bool,
pub created: u64,
pub updated: u64,
#[serde(rename = "averageSignal", default)]
pub average_signal: i32,
}
#[cfg(feature = "csv")]
fn deserialize_optional_u16<'de, D>(d: D) -> std::result::Result<Option<u16>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error as _;
let v = Option::<serde_json::Value>::deserialize(d)?;
match v {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(ref s)) if s.is_empty() => Ok(None),
Some(serde_json::Value::String(s)) => s
.parse::<u16>()
.map(Some)
.map_err(|e| D::Error::custom(format!("expected u16, got {s:?}: {e}"))),
Some(serde_json::Value::Number(n)) => n
.as_u64()
.and_then(|x| u16::try_from(x).ok())
.map(Some)
.ok_or_else(|| D::Error::custom("integer out of u16 range")),
Some(other) => Err(D::Error::custom(format!("expected u16, got {other}"))),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct DumpListing {
pub filename: String,
pub date_utc: String,
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn bbox_validates_ranges() {
assert!(Bbox::new(0.0, 0.0, 1.0, 1.0).is_ok());
assert!(Bbox::new(-91.0, 0.0, 0.0, 1.0).is_err());
assert!(Bbox::new(1.0, 0.0, 0.0, 1.0).is_err());
assert!(Bbox::new(f64::NAN, 0.0, 1.0, 1.0).is_err());
}
#[test]
fn bbox_query_format() {
let b = Bbox::new(10.0, 20.0, 11.0, 21.0).unwrap();
assert_eq!(b.to_query_value(), "10,20,11,21");
}
#[test]
fn radio_serializes_uppercase() {
assert_eq!(serde_json::to_string(&Radio::NbIot).unwrap(), "\"NBIOT\"");
assert_eq!(serde_json::to_string(&Radio::Lte).unwrap(), "\"LTE\"");
}
#[test]
fn cell_changeable_accepts_int_and_bool() {
let body = r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":1,"changeable":1}"#;
let c: Cell = serde_json::from_str(body).unwrap();
assert!(c.changeable);
let body = r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":1,"changeable":false}"#;
let c: Cell = serde_json::from_str(body).unwrap();
assert!(!c.changeable);
}
#[test]
fn cell_id_alias() {
for body in [
r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":42}"#,
r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellId":42}"#,
] {
let c: Cell = serde_json::from_str(body).unwrap();
assert_eq!(c.cell_id, 42);
}
}
#[test]
fn measurement_serializes_act_and_cellid() {
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte).unwrap();
let json = serde_json::to_value(&m).unwrap();
assert_eq!(json["cellid"], 42);
assert_eq!(json["act"], "LTE");
assert!(json.get("signal").is_none(), "None fields must be skipped");
}
#[test]
fn measurement_rejects_non_finite_position() {
assert!(Measurement::new(f64::NAN, 0.0, 1, 1, 1, 1, Radio::Lte).is_err());
assert!(Measurement::new(0.0, f64::INFINITY, 1, 1, 1, 1, Radio::Lte).is_err());
}
#[test]
fn measurement_with_setters_compose() {
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_signal(-95)
.with_rating(50)
.with_ta(3)
.with_speed(12.5);
assert_eq!(m.signal, Some(-95));
assert_eq!(m.rating, Some(50));
assert_eq!(m.ta, Some(3));
assert_eq!(m.speed, Some(12.5));
}
#[test]
fn measurement_validate_catches_non_finite_optional_fields() {
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_speed(f32::NAN);
assert!(m.validate().is_err());
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_direction(f32::INFINITY);
assert!(m.validate().is_err());
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_speed(12.5)
.with_direction(180.0);
assert!(m.validate().is_ok());
}
#[test]
fn format_coordinate_avoids_scientific() {
assert_eq!(format_coordinate(1e-7), "0.0000001");
assert_eq!(format_coordinate(1.0), "1");
assert_eq!(format_coordinate(55.7558), "55.7558");
assert_eq!(format_coordinate(-180.0), "-180");
}
#[test]
fn dump_kind_type_and_file() {
assert_eq!(
DumpKind::World.type_and_file().unwrap(),
("full", "cell_towers.csv.gz".to_string())
);
assert_eq!(
DumpKind::Country(437).type_and_file().unwrap(),
("mcc", "437.csv.gz".to_string())
);
assert_eq!(
DumpKind::Daily { date_utc: "2026-05-10".into() }.type_and_file().unwrap(),
("diff", "OCID-diff-cell-export-2026-05-10-T000000.csv.gz".to_string())
);
}
#[test]
fn dump_kind_rejects_bad_date() {
for bad in ["2026/05/10", "2026-5-10", "garbage", "10-05-2026", "2026-05-10x"] {
let r = DumpKind::Daily { date_utc: bad.into() }.type_and_file();
assert!(r.is_err(), "expected error for {bad:?}");
}
}
#[cfg(feature = "csv")]
#[test]
fn dump_row_deserializes_csv_record() {
let csv = "radio,mcc,net,area,cell,unit,lon,lat,range,samples,changeable,created,updated,averageSignal\n\
LTE,250,1,7,42,127,37.6,55.7,1000,12,1,1700000000,1700001000,-95\n";
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(csv.as_bytes());
let row: DumpRow = rdr.deserialize().next().unwrap().unwrap();
assert_eq!(row.radio, Radio::Lte);
assert_eq!(row.mcc, 250);
assert_eq!(row.mnc, 1);
assert_eq!(row.lac, 7);
assert_eq!(row.cell_id, 42);
assert_eq!(row.unit, Some(127));
assert!(row.changeable);
assert_eq!(row.average_signal, -95);
assert_eq!(row.created, 1_700_000_000);
assert_eq!(row.updated, 1_700_001_000);
}
#[cfg(feature = "csv")]
#[test]
fn dump_row_handles_empty_unit() {
let csv = "radio,mcc,net,area,cell,unit,lon,lat,range,samples,changeable,created,updated,averageSignal\n\
GSM,262,2,801,86355,,13.2,52.5,902,1,1,1700000000,1700001000,0\n";
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(csv.as_bytes());
let row: DumpRow = rdr.deserialize().next().unwrap().unwrap();
assert_eq!(row.unit, None);
}
#[test]
fn dump_kind_country_rejects_out_of_range_mcc() {
assert!(DumpKind::Country(0).type_and_file().is_err());
assert!(DumpKind::Country(199).type_and_file().is_err());
assert!(DumpKind::Country(200).type_and_file().is_ok());
assert!(DumpKind::Country(999).type_and_file().is_ok());
}
#[test]
fn dump_kind_daily_constructor_validates() {
assert!(DumpKind::daily("2026-05-10").is_ok());
assert!(DumpKind::daily("not-a-date").is_err());
assert!(DumpKind::daily("2026/05/10").is_err());
}
proptest! {
#[test]
fn bbox_invariant_holds_when_constructor_succeeds(
lat_min in -90.0f64..90.0,
lat_max in -90.0f64..90.0,
lon_min in -180.0f64..180.0,
lon_max in -180.0f64..180.0,
) {
if let Ok(b) = Bbox::new(lat_min, lon_min, lat_max, lon_max) {
prop_assert!(b.lat_min() < b.lat_max());
prop_assert!(b.lon_min() < b.lon_max());
prop_assert!((-90.0..=90.0).contains(&b.lat_min()));
prop_assert!((-90.0..=90.0).contains(&b.lat_max()));
prop_assert!((-180.0..=180.0).contains(&b.lon_min()));
prop_assert!((-180.0..=180.0).contains(&b.lon_max()));
}
}
#[test]
fn bbox_rejects_inverted(
lat in -90.0f64..90.0,
lon in -180.0f64..180.0,
) {
prop_assert!(Bbox::new(lat, lon, lat, lon).is_err());
prop_assert!(Bbox::new(lat + 1.0, lon, lat, lon + 1.0).is_err());
}
}
}