Skip to main content

sioul_core/
places.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Where postal addresses are, to show contacts on a map. An address is asked
5//! once, when you allow it, of OpenStreetMap's geocoder; the answer (or that
6//! there is none) is kept here (`$XDG_CACHE_HOME/sioul/places.toml`), so an
7//! address leaves this computer once at most.
8
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13/// A point on the map.
14#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
15pub struct Place {
16    pub lat: f64,
17    pub lon: f64,
18}
19
20/// What is known of each address asked: its place, or that none was found.
21#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
22pub struct Places {
23    #[serde(default)]
24    found: BTreeMap<String, Place>,
25    /// Asked, and not found: not asked again.
26    #[serde(default)]
27    unknown: Vec<String>,
28}
29
30/// An address as it is asked and kept: its lines joined, spaces tidied.
31pub fn normalize(address: &str) -> String {
32    address.lines().map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|l| !l.is_empty()).collect::<Vec<_>>().join(", ")
33}
34
35impl Places {
36    pub fn default_path() -> PathBuf {
37        crate::config::cache_dir().join("places.toml")
38    }
39
40    pub fn load(path: &Path) -> Places {
41        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
42    }
43
44    pub fn save(&self, path: &Path) -> Result<(), String> {
45        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
46        if let Some(parent) = path.parent() {
47            std::fs::create_dir_all(parent).map_err(fail)?;
48        }
49        let temporary = path.with_extension("toml.new");
50        std::fs::write(&temporary, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
51        std::fs::rename(&temporary, path).map_err(fail)
52    }
53
54    /// The place of an address, when found.
55    pub fn get(&self, address: &str) -> Option<Place> {
56        self.found.get(&normalize(address)).copied()
57    }
58
59    /// Whether the address was asked already, found or not.
60    pub fn asked(&self, address: &str) -> bool {
61        let key = normalize(address);
62        self.found.contains_key(&key) || self.unknown.contains(&key)
63    }
64
65    pub fn set(&mut self, address: &str, place: Option<Place>) {
66        let key = normalize(address);
67        match place {
68            Some(place) => {
69                self.unknown.retain(|k| *k != key);
70                self.found.insert(key, place);
71            }
72            None => {
73                if !self.unknown.contains(&key) {
74                    self.unknown.push(key);
75                }
76            }
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn an_address_is_asked_once() {
87        let mut places = Places::default();
88        let address = "12 rue de l'Exemple\n  69000   Lyon\nFrance";
89        assert_eq!(normalize(address), "12 rue de l'Exemple, 69000 Lyon, France");
90        assert!(!places.asked(address));
91        places.set(address, Some(Place { lat: 45.76, lon: 4.83 }));
92        places.set("Nowhere 1", None);
93        assert!(places.asked("12 rue de l'Exemple, 69000 Lyon, France") && places.asked("Nowhere 1"));
94        let dir = std::env::temp_dir().join(format!("sioul-places-{}", std::process::id()));
95        let path = dir.join("places.toml");
96        places.save(&path).unwrap();
97        let read = Places::load(&path);
98        assert_eq!(read.get(address), Some(Place { lat: 45.76, lon: 4.83 }));
99        assert!(read.asked("Nowhere 1") && read.get("Nowhere 1").is_none());
100        let _ = std::fs::remove_dir_all(&dir);
101    }
102}