git.lucas.co / cce-weather
weather: now, the next 24 hours and the week
git clone https://git.lucas.co/cce-weather.git

src/config.rs (6.7K)

  1 //! Where the app's settings come from, and what it remembers.
  2 //!
  3 //! - `~/.config/cce/cce-weather/config.kdl` is the user's declared default:
  4 //!   `units "imperial"` / `units "metric"`, `location "Name" lat=… lon=…`
  5 //!   (optional `region="…"`), `refresh_minutes 15`.
  6 //! - `<state>/cce/weather/state.json` is what the app itself changed — a
  7 //!   searched-for place, the unit toggle — plus the last forecast, so a cold
  8 //!   start shows something before the network answers. Its choices win over
  9 //!   the config's, because they are the more recent decision.
 10 
 11 use serde::{Deserialize, Serialize};
 12 
 13 use crate::api::Forecast;
 14 
 15 const APP: &str = "cce-weather";
 16 pub const DEFAULT_REFRESH_MINUTES: u64 = 15;
 17 
 18 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
 19 #[serde(rename_all = "lowercase")]
 20 pub enum Units {
 21     #[default]
 22     Metric,
 23     Imperial,
 24 }
 25 
 26 impl Units {
 27     pub fn temp_suffix(self) -> &'static str {
 28         match self {
 29             Units::Metric => "°C",
 30             Units::Imperial => "°F",
 31         }
 32     }
 33 
 34     pub fn wind_suffix(self) -> &'static str {
 35         match self {
 36             Units::Metric => "km/h",
 37             Units::Imperial => "mph",
 38         }
 39     }
 40 
 41     pub fn toggled(self) -> Self {
 42         match self {
 43             Units::Metric => Units::Imperial,
 44             Units::Imperial => Units::Metric,
 45         }
 46     }
 47 
 48     fn parse(s: &str) -> Option<Self> {
 49         match s.to_ascii_lowercase().as_str() {
 50             "metric" | "c" | "celsius" => Some(Units::Metric),
 51             "imperial" | "f" | "fahrenheit" => Some(Units::Imperial),
 52             _ => None,
 53         }
 54     }
 55 
 56     /// No config, no state: follow the measurement locale. Only the US (and
 57     /// a couple of others) read Fahrenheit.
 58     fn from_locale() -> Self {
 59         let loc = ["LC_ALL", "LC_MEASUREMENT", "LANG"]
 60             .iter()
 61             .filter_map(|k| std::env::var(k).ok())
 62             .find(|v| !v.is_empty())
 63             .unwrap_or_default();
 64         let region = loc.split(['.', '@']).next().unwrap_or("").rsplit('_').next().unwrap_or("");
 65         match region {
 66             "US" | "LR" | "MM" => Units::Imperial,
 67             _ => Units::Metric,
 68         }
 69     }
 70 }
 71 
 72 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 73 pub struct Location {
 74     pub name: String,
 75     /// "State, Country" as the geocoder gives it; may be empty.
 76     #[serde(default)]
 77     pub region: String,
 78     pub latitude: f64,
 79     pub longitude: f64,
 80 }
 81 
 82 /// Only choices made IN the app are recorded here (a search, the toggle), so
 83 /// an untouched setting keeps following the config when that is edited.
 84 #[derive(Debug, Default, Serialize, Deserialize)]
 85 pub struct State {
 86     #[serde(default)]
 87     pub location: Option<Location>,
 88     #[serde(default)]
 89     pub units: Option<Units>,
 90     #[serde(default)]
 91     pub forecast: Option<Forecast>,
 92     /// The place `forecast` is for.
 93     #[serde(default)]
 94     pub forecast_location: Option<Location>,
 95     /// Unix seconds when `forecast` was fetched.
 96     #[serde(default)]
 97     pub fetched_at: Option<i64>,
 98 }
 99 
100 pub struct Config {
101     pub location: Option<Location>,
102     pub units: Option<Units>,
103     pub refresh_minutes: u64,
104 }
105 
106 pub fn load_config() -> Config {
107     let path = cce_ui::config::get_app_config_path(APP);
108     let text = std::fs::read_to_string(&path).unwrap_or_default();
109     parse_config(&text).unwrap_or_else(|e| {
110         log::warn!("{}: {e}", path.display());
111         parse_config("").unwrap()
112     })
113 }
114 
115 fn parse_config(text: &str) -> Result<Config, String> {
116     let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
117     let units = doc
118         .get("units")
119         .and_then(|n| n.entries().first())
120         .and_then(|e| e.value().as_string())
121         .and_then(Units::parse);
122     let location = doc.get("location").and_then(|n| {
123         let num = |k: &str| {
124             n.get(k).and_then(|e| e.value().as_f64().or_else(|| e.value().as_i64().map(|i| i as f64)))
125         };
126         let name = n.entries().iter().find(|e| e.name().is_none())?.value().as_string()?;
127         Some(Location {
128             name: name.to_string(),
129             region: n.get("region").and_then(|e| e.value().as_string()).unwrap_or("").to_string(),
130             latitude: num("lat")?,
131             longitude: num("lon")?,
132         })
133     });
134     let refresh_minutes = doc
135         .get("refresh_minutes")
136         .and_then(|n| n.entries().first())
137         .and_then(|e| e.value().as_i64())
138         .map(|m| m.clamp(5, 24 * 60) as u64)
139         .unwrap_or(DEFAULT_REFRESH_MINUTES);
140     Ok(Config { location, units, refresh_minutes })
141 }
142 
143 fn state_path() -> std::path::PathBuf {
144     cce_ui::config::cce_state_dir().join("weather").join("state.json")
145 }
146 
147 pub fn load_state() -> State {
148     std::fs::read_to_string(state_path())
149         .ok()
150         .and_then(|t| serde_json::from_str(&t).ok())
151         .unwrap_or_default()
152 }
153 
154 /// Written whole through a temp file and a rename, so a crash mid-write
155 /// leaves the previous state rather than half a JSON document.
156 pub fn save_state(state: &State) {
157     let path = state_path();
158     let result = (|| -> std::io::Result<()> {
159         std::fs::create_dir_all(path.parent().unwrap())?;
160         let tmp = path.with_extension("json.tmp");
161         std::fs::write(&tmp, serde_json::to_vec(state)?)?;
162         std::fs::rename(&tmp, &path)
163     })();
164     if let Err(e) = result {
165         log::warn!("saving {}: {e}", path.display());
166     }
167 }
168 
169 /// The units in force: the app's own toggle, else the config, else the locale.
170 pub fn resolve_units(state: &State, config: &Config) -> Units {
171     state.units.or(config.units).unwrap_or_else(Units::from_locale)
172 }
173 
174 #[cfg(test)]
175 mod tests {
176     use super::*;
177 
178     #[test]
179     fn parses_full_config() {
180         let c = parse_config(
181             "units \"imperial\"\nlocation \"Brooklyn\" lat=40.65 lon=-73.95 region=\"New York, United States\"\nrefresh_minutes 30\n",
182         )
183         .unwrap();
184         assert_eq!(c.units, Some(Units::Imperial));
185         let l = c.location.unwrap();
186         assert_eq!(l.name, "Brooklyn");
187         assert_eq!(l.region, "New York, United States");
188         assert!((l.longitude + 73.95).abs() < 1e-9);
189         assert_eq!(c.refresh_minutes, 30);
190     }
191 
192     #[test]
193     fn empty_config_is_all_defaults() {
194         let c = parse_config("").unwrap();
195         assert!(c.location.is_none() && c.units.is_none());
196         assert_eq!(c.refresh_minutes, DEFAULT_REFRESH_MINUTES);
197     }
198 
199     #[test]
200     fn location_needs_both_coordinates() {
201         let c = parse_config("location \"Nowhere\" lat=1.0\n").unwrap();
202         assert!(c.location.is_none());
203     }
204 
205     #[test]
206     fn integer_coordinates_are_accepted() {
207         let c = parse_config("location \"Null Island\" lat=0 lon=0\n").unwrap();
208         assert_eq!(c.location.unwrap().latitude, 0.0);
209     }
210 }