Skip to main content

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}