use serde::Deserialize;
use crate::error::{ApiErrorCode, Error, ParseError, Result, truncate_for_diagnostic};
#[cfg(feature = "csv")]
use crate::types::Cell;
use crate::types::DumpListing;
#[cfg(feature = "csv")]
use crate::types::DumpRow;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ApiError {
#[serde(rename = "err", alias = "error")]
code: u16,
#[serde(rename = "msg", alias = "message", default)]
message: String,
}
pub(crate) fn detect_api_error(body: &str) -> Option<Error> {
let trimmed = body.trim_start();
if !trimmed.starts_with('{') {
return None;
}
let parsed = serde_json::from_str::<ApiError>(body).ok()?;
if parsed.code == 0 {
return None;
}
let code = ApiErrorCode::from_code(parsed.code);
let message = if parsed.message.is_empty() {
code.to_string()
} else {
truncate_for_diagnostic(&parsed.message)
};
Some(Error::Api { code, message })
}
pub(crate) fn parse_json<T: for<'de> Deserialize<'de>>(body: &str) -> Result<T> {
if let Some(err) = detect_api_error(body) {
return Err(err);
}
serde_json::from_str::<T>(body).map_err(|e| {
Error::Parse(ParseError::with_source(
format!("json parse: {}", truncate_for_diagnostic(body)),
e,
))
})
}
pub(crate) fn finalize_response_body(
status: reqwest::StatusCode,
bytes: Vec<u8>,
) -> Result<String> {
let body = String::from_utf8(bytes).map_err(|e| {
Error::Parse(crate::error::ParseError::with_source("response is not valid UTF-8", e))
})?;
check_http_status(status, &body)?;
tracing::trace!(%status, body_len = body.len(), "received response");
Ok(body)
}
pub(crate) fn check_http_status(status: reqwest::StatusCode, body: &str) -> Result<()> {
if status.is_success() {
return Ok(());
}
let code = match status.as_u16() {
400 => ApiErrorCode::InvalidInput,
401 => ApiErrorCode::InvalidApiKey,
403 => ApiErrorCode::NeedsWhitelisting,
429 => ApiErrorCode::DailyLimitExceeded,
500 => ApiErrorCode::ServerError,
503 => ApiErrorCode::TooManyRequests,
other => ApiErrorCode::Unknown(other),
};
let message = if body.is_empty() {
status.canonical_reason().unwrap_or("HTTP error").to_string()
} else {
truncate_for_diagnostic(body)
};
Err(Error::Api { code, message })
}
pub(crate) const GZIP_MAGIC: [u8; 2] = [0x1F, 0x8B];
const UPLOAD_OK_CSV: &str = "0,OK";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DownloadEnvelope {
status: String,
message: String,
}
pub(crate) fn detect_download_error(prefix: &[u8]) -> Option<Error> {
if prefix.starts_with(&GZIP_MAGIC) {
return None;
}
let text = match std::str::from_utf8(prefix) {
Ok(s) => s,
Err(_) => {
tracing::trace!("download head is not valid UTF-8 — treating as opaque");
return None;
}
};
let env: DownloadEnvelope = match serde_json::from_str(text.trim()) {
Ok(e) => e,
Err(_) => {
tracing::trace!("download head is not a known JSON envelope");
return None;
}
};
if env.status != "error" {
tracing::trace!(status = %env.status, "download envelope status != error");
return None;
}
let code = match env.message.as_str() {
"INVALID_TOKEN" => ApiErrorCode::InvalidApiKey,
"RATE_LIMITED" => ApiErrorCode::TooManyRequests,
_ => ApiErrorCode::Unknown(0),
};
Some(Error::Api {
code,
message: truncate_for_diagnostic(&env.message),
})
}
pub(crate) fn validate_dump_head(status: reqwest::StatusCode, head: &[u8]) -> Result<()> {
if let Some(api_err) = detect_download_error(head) {
return Err(api_err);
}
if !status.is_success() {
let body = String::from_utf8_lossy(head);
check_http_status(status, body.as_ref())?;
}
if !head.starts_with(&GZIP_MAGIC) {
return Err(Error::Parse(ParseError::new(format!(
"expected gzip stream, got: {}",
truncate_for_diagnostic(&String::from_utf8_lossy(head))
))));
}
Ok(())
}
pub(crate) fn parse_diff_listing(html: &str) -> Vec<DumpListing> {
use std::collections::BTreeSet;
const PREFIX: &str = "OCID-diff-cell-export-";
const SUFFIX: &str = "-T000000.csv.gz";
const DATE_LEN: usize = 10;
let dates: BTreeSet<&str> = html
.match_indices(PREFIX)
.filter_map(|(start, _)| {
let date_start = start + PREFIX.len();
let date_end = date_start + DATE_LEN;
let date = html.get(date_start..date_end)?;
if !crate::types::is_iso_date(date) {
return None;
}
let suffix_end = date_end + SUFFIX.len();
html.get(date_end..suffix_end).filter(|s| *s == SUFFIX)?;
Some(date)
})
.collect();
dates
.into_iter()
.map(|date_utc| DumpListing {
filename: format!("{PREFIX}{date_utc}{SUFFIX}"),
date_utc: date_utc.to_string(),
})
.collect()
}
#[cfg(feature = "csv")]
pub fn parse_dump_csv<R: std::io::Read>(reader: R) -> impl Iterator<Item = Result<DumpRow>> {
let rdr = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.from_reader(reader);
rdr.into_deserialize::<DumpRow>()
.map(|r| r.map_err(|e| Error::Parse(ParseError::with_source("dump csv row", e))))
}
pub(crate) fn check_upload_response(body: &str) -> Result<()> {
if let Some(err) = detect_api_error(body) {
return Err(err);
}
let trimmed = body.trim();
if trimmed.starts_with('{') {
return Ok(());
}
if trimmed == UPLOAD_OK_CSV || trimmed.eq_ignore_ascii_case("ok") {
return Ok(());
}
Err(Error::Parse(ParseError::new(format!(
"unexpected upload response: {}",
truncate_for_diagnostic(body)
))))
}
#[cfg(feature = "csv")]
pub(crate) fn parse_cells_csv(body: &str) -> Result<Vec<Cell>> {
if body.is_empty() {
return Ok(Vec::new());
}
if let Some(err) = detect_api_error(body) {
return Err(err);
}
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_reader(body.as_bytes());
let mut out = Vec::new();
for record in reader.deserialize::<Row>() {
let r = record.map_err(|e| Error::Parse(ParseError::with_source("csv row", e)))?;
out.push(r.into());
}
Ok(out)
}
#[cfg(feature = "csv")]
impl From<Row> for Cell {
fn from(r: Row) -> Self {
Cell {
lat: r.lat,
lon: r.lon,
mcc: r.mcc,
mnc: r.mnc,
lac: r.lac,
cell_id: r.cellid,
range: r.range,
samples: r.samples,
changeable: r.changeable != 0,
avg_signal: r.avg_signal,
radio: r.radio,
}
}
}
#[cfg(feature = "csv")]
#[derive(Debug, Deserialize)]
struct Row {
lat: f64,
lon: f64,
mcc: u16,
mnc: u16,
lac: u32,
cellid: u64,
#[serde(rename = "averageSignalStrength", default)]
avg_signal: i32,
#[serde(default)]
range: u32,
#[serde(default)]
samples: u32,
#[serde(default)]
changeable: u8,
#[serde(default)]
radio: Option<crate::types::Radio>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Cell;
#[test]
fn parse_json_success() {
let body = r#"{"lat":1.0,"lon":2.0,"mcc":250,"mnc":1,"lac":7,"cellid":42,"range":100,"samples":3,"changeable":1,"averageSignalStrength":-95}"#;
let cell: Cell = parse_json(body).unwrap();
assert_eq!(cell.cell_id, 42);
assert_eq!(cell.avg_signal, -95);
assert!(cell.changeable);
}
#[test]
fn parse_json_api_error() {
let body = r#"{"err":2,"msg":"invalid api key"}"#;
let r: Result<Cell> = parse_json(body);
match r.unwrap_err() {
Error::Api { code, message } => {
assert_eq!(code, ApiErrorCode::InvalidApiKey);
assert_eq!(message, "invalid api key");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn parse_json_alt_keys() {
let body = r#"{"error":7,"message":"daily limit"}"#;
let r: Result<Cell> = parse_json(body);
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::DailyLimitExceeded),
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn parse_json_does_not_misclassify_payloads_with_extra_fields() {
let body = r#"{"err":0,"msg":"","lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":1}"#;
let cell: Result<Cell> = parse_json(body);
match cell {
Ok(c) => assert_eq!(c.cell_id, 1),
Err(Error::Parse(_)) => {}
Err(other) => panic!("must not be an Api error, got {other:?}"),
}
}
#[test]
fn http_status_maps_codes() {
let r = check_http_status(reqwest::StatusCode::TOO_MANY_REQUESTS, "");
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::DailyLimitExceeded),
_ => panic!(),
}
}
#[test]
fn http_status_truncates_long_body() {
let body = "x".repeat(2000);
let r = check_http_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &body);
match r.unwrap_err() {
Error::Api { message, .. } => {
assert!(message.len() < body.len());
assert!(message.contains("(2000 bytes total)"));
}
_ => panic!(),
}
}
#[test]
fn check_upload_response_accepts_csv_ok() {
check_upload_response("0,OK").unwrap();
}
#[test]
fn check_upload_response_accepts_json_ok() {
check_upload_response(r#"{"code":0,"status":"OK"}"#).unwrap();
}
#[test]
fn check_upload_response_rejects_api_error() {
let r = check_upload_response(r#"{"err":2,"msg":"bad key"}"#);
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::InvalidApiKey),
_ => panic!(),
}
}
#[cfg(feature = "csv")]
#[test]
fn parse_csv_basic() {
let body = "lat,lon,mcc,mnc,lac,cellid,averageSignalStrength,range,samples,changeable,radio\n\
1.0,2.0,250,1,7,42,-95,100,3,1,LTE\n\
3.5,4.5,250,2,8,43,-100,200,5,0,GSM\n";
let cells = parse_cells_csv(body).unwrap();
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].radio, Some(crate::types::Radio::Lte));
assert!(cells[0].changeable);
assert_eq!(cells[1].cell_id, 43);
assert!(!cells[1].changeable);
}
#[cfg(feature = "csv")]
#[test]
fn parse_csv_empty_returns_empty() {
assert!(parse_cells_csv("").unwrap().is_empty());
}
#[cfg(feature = "csv")]
#[test]
fn parse_csv_propagates_api_error() {
let body = r#"{"err":2,"msg":"bad key"}"#;
let r = parse_cells_csv(body);
match r.unwrap_err() {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::InvalidApiKey),
_ => panic!(),
}
}
#[test]
fn detect_download_error_passes_through_gzip() {
let bytes = b"\x1F\x8Bsomething";
assert!(detect_download_error(bytes).is_none());
}
#[test]
fn detect_download_error_invalid_token() {
let body = br#"{"status":"error","message":"INVALID_TOKEN"}"#;
match detect_download_error(body) {
Some(Error::Api { code, .. }) => assert_eq!(code, ApiErrorCode::InvalidApiKey),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn detect_download_error_rate_limited() {
let body = br#"{"status":"error","message":"RATE_LIMITED"}"#;
match detect_download_error(body) {
Some(Error::Api { code, .. }) => assert_eq!(code, ApiErrorCode::TooManyRequests),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn detect_download_error_unknown_message() {
let body = br#"{"status":"error","message":"WAT"}"#;
match detect_download_error(body) {
Some(Error::Api { code, message }) => {
assert_eq!(code, ApiErrorCode::Unknown(0));
assert_eq!(message, "WAT");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn detect_download_error_returns_none_for_unknown_text() {
assert!(detect_download_error(b"random non-json text").is_none());
}
#[test]
fn parse_diff_listing_dedups_and_sorts() {
let html = r#"
<html><body>
<a href="?token=X&type=diff&file=OCID-diff-cell-export-2026-05-09-T000000.csv.gz">9</a>
<a href="?token=X&type=diff&file=OCID-diff-cell-export-2026-05-10-T000000.csv.gz">10</a>
<a href="?token=X&type=diff&file=OCID-diff-cell-export-2026-05-09-T000000.csv.gz">9 again</a>
<a href="cell_towers.csv.gz">noise</a>
garbage OCID-diff-cell-export-NOT-A-DATE-T000000.csv.gz garbage
</body></html>
"#;
let listings = parse_diff_listing(html);
let dates: Vec<&str> = listings.iter().map(|l| l.date_utc.as_str()).collect();
assert_eq!(dates, vec!["2026-05-09", "2026-05-10"]);
assert_eq!(
listings[0].filename,
"OCID-diff-cell-export-2026-05-09-T000000.csv.gz"
);
}
#[cfg(feature = "csv")]
#[test]
fn parse_dump_csv_yields_rows() {
let body = "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\
GSM,262,2,801,86355,,13.2,52.5,902,1,1,1700000000,1700001000,0\n";
let rows: Vec<_> = parse_dump_csv(body.as_bytes())
.collect::<Result<Vec<_>>>()
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].cell_id, 42);
assert_eq!(rows[1].unit, None);
}
#[cfg(feature = "csv")]
#[test]
fn parse_dump_csv_propagates_row_error() {
let body = "radio,mcc,net,area,cell,unit,lon,lat,range,samples,changeable,created,updated,averageSignal\n\
LTE,not_a_number,1,7,42,,37.6,55.7,1000,12,1,1700000000,1700001000,0\n";
let mut iter = parse_dump_csv(body.as_bytes());
match iter.next() {
Some(Err(Error::Parse(_))) => {}
other => panic!("expected Err(Parse), got {other:?}"),
}
}
}