videre_core/location.rs
1use reverse_geocoder::ReverseGeocoder;
2use rusqlite::Connection;
3use std::sync::OnceLock;
4
5/// Idempotent migration: adds `file_hashes.location_name` if it doesn't
6/// already exist. Mirrors the `ALTER TABLE faces ADD COLUMN is_primary`
7/// pattern in face_db.rs, errors (column already exists) are ignored.
8pub fn ensure_location_column(conn: &Connection) {
9 let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN location_name TEXT");
10}
11
12/// Process-wide, lazily-built reverse geocoder. `ReverseGeocoder::new()`
13/// parses an embedded ~144,564-row / 7.8MB `cities.csv` and builds a KD-tree
14/// from scratch, which is expensive to redo per lookup. Built once per
15/// process and reused by every caller (both the single-call `location_name`
16/// below and any bulk caller using `geocoder()` directly).
17static GEOCODER: OnceLock<ReverseGeocoder> = OnceLock::new();
18
19/// Returns the process-wide reverse geocoder, building it on first access.
20/// Callers doing many lookups in a loop (e.g. `videre watch`'s location stage)
21/// should call this once and reuse the reference rather than calling
22/// `location_name` per coordinate, since `location_name` itself goes through
23/// this same cached instance but still incurs a function-call/lookup
24/// pattern per site, using `geocoder()` directly makes the "build once"
25/// intent explicit at bulk call sites.
26pub fn geocoder() -> &'static ReverseGeocoder {
27 GEOCODER.get_or_init(ReverseGeocoder::new)
28}
29
30/// Reverse-geocodes (lat, lon) to a human-readable "City, Country" string
31/// using an offline GeoNames-derived dataset (no network calls). Always
32/// returns Some(..) since the bundled dataset covers the whole globe with a
33/// nearest-city match, there's always some nearest record.
34///
35/// Uses a process-wide cached `ReverseGeocoder` (see `geocoder()`), so
36/// repeated calls, whether from a single on-demand lookup or a loop over
37/// many coordinates, only pay the dataset-parsing/KD-tree-build cost once.
38pub fn location_name(lat: f64, lon: f64) -> Option<String> {
39 let result = geocoder().search((lat, lon));
40 let record = &result.record;
41 if record.name.is_empty() {
42 None
43 } else {
44 Some(format!("{}, {}", record.name, record.cc))
45 }
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn ensure_location_column_is_idempotent() {
54 let conn = Connection::open_in_memory().unwrap();
55 conn.execute_batch("CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);")
56 .unwrap();
57 crate::db::ensure_file_hashes_columns(&conn);
58 ensure_location_column(&conn);
59 ensure_location_column(&conn); // second call must not error
60 conn.execute(
61 "UPDATE file_hashes SET location_name = 'Paris, FR' WHERE path = 'x'",
62 [],
63 )
64 .unwrap();
65 }
66
67 #[test]
68 fn location_name_resolves_known_city() {
69 // Coordinates for central Paris, France.
70 let name = location_name(48.8566, 2.3522).unwrap();
71 assert!(
72 name.contains("FR"),
73 "expected France country code, got: {name}"
74 );
75 }
76}