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 }