Skip to main content

sioul_sync/
geocode.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Where an address is, asked of OpenStreetMap's geocoder (Nominatim), under
5//! its usage policy: Sioul says who it is, asks one address at a time, at
6//! most one a second (the caller waits), and keeps the answers
7//! (`sioul_core::places`), so an address is never asked twice.
8
9use crate::SyncError;
10use sioul_core::places::Place;
11use std::time::Duration;
12
13const ENDPOINT: &str = "https://nominatim.openstreetmap.org/search";
14
15/// How long to wait between two questions.
16pub const PAUSE: Duration = Duration::from_millis(1100);
17
18/// The place of one address; None when the geocoder knows none.
19pub fn locate(address: &str, language: &str) -> Result<Option<Place>, SyncError> {
20    locate_at(ENDPOINT, address, language)
21}
22
23fn locate_at(endpoint: &str, address: &str, language: &str) -> Result<Option<Place>, SyncError> {
24    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(20))).http_status_as_error(false).build().into();
25    let query = sioul_core::places::normalize(address);
26    let mut response = agent
27        .get(endpoint)
28        .query("format", "jsonv2")
29        .query("limit", "1")
30        .query("q", &query)
31        .header("User-Agent", concat!("Sioul/", env!("CARGO_PKG_VERSION"), " (desktop mail and contacts client)"))
32        .header("Accept-Language", language)
33        .call()
34        .map_err(|e| SyncError::Network(e.to_string()))?;
35    let status = response.status().as_u16();
36    let text = response.body_mut().read_to_string().unwrap_or_default();
37    if status != 200 {
38        return Err(SyncError::Server(format!("Nominatim: {status}")));
39    }
40    let found: serde_json::Value = serde_json::from_str(&text).map_err(|e| SyncError::Server(e.to_string()))?;
41    let first = found.as_array().and_then(|a| a.first());
42    let number = |key: &str| first.and_then(|f| f[key].as_str()).and_then(|v| v.parse::<f64>().ok());
43    Ok(match (number("lat"), number("lon")) {
44        (Some(lat), Some(lon)) => Some(Place { lat, lon }),
45        _ => None,
46    })
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use std::io::{Read, Write};
53
54    fn stand_in(body: &'static str) -> (String, std::thread::JoinHandle<String>) {
55        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
56        let url = format!("http://{}/search", listener.local_addr().unwrap());
57        let served = std::thread::spawn(move || {
58            let (mut stream, _) = listener.accept().unwrap();
59            stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
60            let mut request = vec![0u8; 8192];
61            let n = stream.read(&mut request).unwrap();
62            write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
63            String::from_utf8_lossy(&request[..n]).to_string()
64        });
65        (url, served)
66    }
67
68    #[test]
69    fn an_address_found_or_not() {
70        let (url, served) = stand_in(r#"[{"place_id":1,"lat":"45.5","lon":"4.25","display_name":"3, Rue des Exemples, Exempleville"}]"#);
71        let place = locate_at(&url, "3 rue des Exemples\n99999 Exempleville", "fr").unwrap().unwrap();
72        assert_eq!((place.lat, place.lon), (45.5, 4.25));
73        let request = served.join().unwrap();
74        assert!(request.contains("q=3+rue+des+Exemples%2C+99999+Exempleville") || request.contains("q=3%20rue%20des%20Exemples%2C%2099999%20Exempleville"), "{request}");
75        assert!(request.to_lowercase().contains("user-agent: sioul/"), "{request}");
76        let (url, served) = stand_in("[]");
77        assert_eq!(locate_at(&url, "Nowhere", "en").unwrap(), None);
78        served.join().unwrap();
79    }
80}