use reverse_geocoder::ReverseGeocoder;
use rusqlite::Connection;
use std::sync::OnceLock;
const CITIES_CSV: &str = include_str!("../data/cities.csv");
pub fn ensure_location_column(conn: &Connection) {
let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN location_name TEXT");
}
static GEOCODER: OnceLock<ReverseGeocoder> = OnceLock::new();
pub fn geocoder() -> &'static ReverseGeocoder {
GEOCODER.get_or_init(|| {
match materialize_cities_csv().and_then(|p| Ok(ReverseGeocoder::from_path(p)?)) {
Ok(g) => g,
Err(e) => {
eprintln!(
"warning: could not load videre's place-name data ({e}); \
falling back to the ASCII-only built-in, so names like \
Üsküdar will appear as UEskuedar"
);
ReverseGeocoder::new()
}
}
})
}
fn materialize_cities_csv() -> anyhow::Result<std::path::PathBuf> {
use std::io::Write;
let dir = crate::home::videre_home()?.join("geo");
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("cities-{}.csv", CITIES_CSV.len()));
if path.exists() {
return Ok(path);
}
let tmp = dir.join(format!(
"cities-{}.{}.tmp",
CITIES_CSV.len(),
std::process::id()
));
let mut f = std::fs::File::create(&tmp)?;
f.write_all(CITIES_CSV.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, &path)?;
Ok(path)
}
pub fn location_name(lat: f64, lon: f64) -> Option<String> {
let result = geocoder().search((lat, lon));
let record = &result.record;
if record.name.is_empty() {
None
} else {
Some(format!("{}, {}", record.name, record.cc))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_location_column_is_idempotent() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);")
.unwrap();
crate::db::ensure_file_hashes_columns(&conn);
ensure_location_column(&conn);
ensure_location_column(&conn); conn.execute(
"UPDATE file_hashes SET location_name = 'Paris, FR' WHERE path = 'x'",
[],
)
.unwrap();
}
#[test]
fn location_name_resolves_known_city() {
let name = location_name(48.8566, 2.3522).unwrap();
assert!(
name.contains("FR"),
"expected France country code, got: {name}"
);
}
#[test]
fn place_names_keep_their_diacritics() {
for (lat, lon, want, mangled) in [
(41.02274, 29.01366, "Üsküdar", "UEskuedar"),
(55.60587, 13.00073, "Malmö", "Malmoe"),
] {
let got = location_name(lat, lon).unwrap();
assert!(got.starts_with(want), "expected {want}, got {got}");
assert!(!got.contains(mangled), "still ASCII-mangled: {got}");
}
}
#[test]
fn a_city_is_named_rather_than_one_of_its_administrative_slices() {
let got = location_name(44.4897, 26.0884).unwrap();
assert!(
got.starts_with("Bucharest"),
"expected Bucharest, got {got}"
);
}
#[test]
fn numbered_administrative_slices_are_absent_from_the_data() {
for slice in ["Sector 1", "Sector 6", "Ward 3", "Zona 179"] {
assert!(
!CITIES_CSV.contains(&format!(",{slice},")),
"{slice} is back in the dataset; regenerate with the filter in data/README.md"
);
}
}
#[test]
fn the_bundled_data_is_actually_unicode() {
let non_ascii = CITIES_CSV
.lines()
.skip(1)
.filter(|l| l.chars().any(|c| !c.is_ascii()))
.count();
assert!(
non_ascii > 30_000,
"only {non_ascii} rows carry non-ASCII names; the data was probably \
built from GeoNames' asciiname column again"
);
}
}