Skip to main content

ramadan_cli/
ramadan_config.rs

1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Result, anyhow};
5use directories::BaseDirs;
6use serde::{Deserialize, Serialize};
7
8use crate::geo::GeoLocation;
9use crate::recommendations::{get_recommended_method, get_recommended_school};
10
11const DEFAULT_METHOD: i64 = 2;
12const DEFAULT_SCHOOL: i64 = 0;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15struct RamadanConfigStore {
16    latitude: Option<f64>,
17    longitude: Option<f64>,
18    city: Option<String>,
19    country: Option<String>,
20    method: Option<i64>,
21    school: Option<i64>,
22    timezone: Option<String>,
23    first_roza_date: Option<String>,
24    format24h: Option<bool>,
25}
26
27#[derive(Debug, Clone)]
28pub struct StoredLocation {
29    pub city: Option<String>,
30    pub country: Option<String>,
31    pub latitude: Option<f64>,
32    pub longitude: Option<f64>,
33}
34
35#[derive(Debug, Clone)]
36pub struct StoredPrayerSettings {
37    pub method: i64,
38    pub school: i64,
39    pub timezone: Option<String>,
40}
41
42fn config_base_dir() -> PathBuf {
43    if let Ok(path) = std::env::var("RAMADAN_CLI_CONFIG_DIR") {
44        return PathBuf::from(path);
45    }
46
47    let is_test = std::env::var("VITEST").as_deref() == Ok("true")
48        || std::env::var("NODE_ENV").as_deref() == Ok("test");
49    if is_test {
50        return PathBuf::from("/tmp");
51    }
52
53    if let Some(base) = BaseDirs::new() {
54        return base.config_dir().to_path_buf();
55    }
56
57    PathBuf::from(".")
58}
59
60fn config_dir() -> PathBuf {
61    config_base_dir().join("ramadan-cli")
62}
63
64fn config_file_path() -> PathBuf {
65    config_dir().join("config.json")
66}
67
68fn default_store() -> RamadanConfigStore {
69    RamadanConfigStore {
70        method: Some(DEFAULT_METHOD),
71        school: Some(DEFAULT_SCHOOL),
72        format24h: Some(false),
73        ..RamadanConfigStore::default()
74    }
75}
76
77fn load_store() -> RamadanConfigStore {
78    let path = config_file_path();
79    let raw = fs::read_to_string(path);
80    match raw {
81        Ok(contents) => serde_json::from_str::<RamadanConfigStore>(&contents)
82            .unwrap_or_else(|_| default_store()),
83        Err(_) => default_store(),
84    }
85}
86
87fn save_store(store: &RamadanConfigStore) -> Result<()> {
88    let dir = config_dir();
89    fs::create_dir_all(&dir)?;
90    let json = serde_json::to_string_pretty(store)?;
91    fs::write(config_file_path(), json)?;
92    Ok(())
93}
94
95pub fn should_apply_recommended_method(current_method: i64, recommended_method: i64) -> bool {
96    current_method == DEFAULT_METHOD || current_method == recommended_method
97}
98
99pub fn should_apply_recommended_school(current_school: i64, recommended_school: i64) -> bool {
100    current_school == DEFAULT_SCHOOL || current_school == recommended_school
101}
102
103pub fn get_stored_location() -> StoredLocation {
104    let store = load_store();
105    StoredLocation {
106        city: store.city,
107        country: store.country,
108        latitude: store.latitude,
109        longitude: store.longitude,
110    }
111}
112
113pub fn has_stored_location() -> bool {
114    let location = get_stored_location();
115    let has_city_country = location.city.is_some() && location.country.is_some();
116    let has_coords = location.latitude.is_some() && location.longitude.is_some();
117    has_city_country || has_coords
118}
119
120pub fn get_stored_prayer_settings() -> StoredPrayerSettings {
121    let store = load_store();
122    StoredPrayerSettings {
123        method: store.method.unwrap_or(DEFAULT_METHOD),
124        school: store.school.unwrap_or(DEFAULT_SCHOOL),
125        timezone: store.timezone,
126    }
127}
128
129pub fn set_stored_location(location: &StoredLocation) -> Result<()> {
130    let mut store = load_store();
131
132    if let Some(city) = &location.city {
133        store.city = Some(city.clone());
134    }
135    if let Some(country) = &location.country {
136        store.country = Some(country.clone());
137    }
138    if let Some(latitude) = location.latitude {
139        store.latitude = Some(latitude);
140    }
141    if let Some(longitude) = location.longitude {
142        store.longitude = Some(longitude);
143    }
144
145    save_store(&store)
146}
147
148pub fn set_stored_timezone(timezone: Option<&str>) -> Result<()> {
149    let mut store = load_store();
150    if let Some(timezone) = timezone {
151        if !timezone.trim().is_empty() {
152            store.timezone = Some(timezone.trim().to_string());
153        }
154    }
155    save_store(&store)
156}
157
158pub fn set_stored_method(method: i64) -> Result<()> {
159    let mut store = load_store();
160    store.method = Some(method);
161    save_store(&store)
162}
163
164pub fn set_stored_school(school: i64) -> Result<()> {
165    let mut store = load_store();
166    store.school = Some(school);
167    save_store(&store)
168}
169
170pub fn get_stored_first_roza_date() -> Option<String> {
171    let store = load_store();
172    store.first_roza_date
173}
174
175fn is_valid_iso_date(value: &str) -> bool {
176    let parts: Vec<&str> = value.split('-').collect();
177    if parts.len() != 3 {
178        return false;
179    }
180
181    let year = parts[0].parse::<i32>().ok();
182    let month = parts[1].parse::<u32>().ok();
183    let day = parts[2].parse::<u32>().ok();
184
185    match (year, month, day) {
186        (Some(y), Some(m), Some(d)) => chrono::NaiveDate::from_ymd_opt(y, m, d).is_some(),
187        _ => false,
188    }
189}
190
191pub fn set_stored_first_roza_date(first_roza_date: &str) -> Result<()> {
192    if !is_valid_iso_date(first_roza_date) {
193        return Err(anyhow!("Invalid first roza date. Use YYYY-MM-DD."));
194    }
195
196    let mut store = load_store();
197    store.first_roza_date = Some(first_roza_date.to_string());
198    save_store(&store)
199}
200
201pub fn clear_stored_first_roza_date() -> Result<()> {
202    let mut store = load_store();
203    store.first_roza_date = None;
204    save_store(&store)
205}
206
207pub fn clear_ramadan_config() -> Result<()> {
208    let config_path = config_file_path();
209    if config_path.exists() {
210        fs::remove_file(config_path)?;
211    }
212
213    // Best-effort cleanup of legacy app config location.
214    let legacy = config_base_dir().join("azaan").join("config.json");
215    if legacy.exists() {
216        let _ = fs::remove_file(legacy);
217    }
218
219    Ok(())
220}
221
222fn maybe_set_recommended_method(store: &mut RamadanConfigStore, country: &str) {
223    let Some(recommended_method) = get_recommended_method(country) else {
224        return;
225    };
226
227    let current_method = store.method.unwrap_or(DEFAULT_METHOD);
228    if !should_apply_recommended_method(current_method, recommended_method) {
229        return;
230    }
231
232    store.method = Some(recommended_method);
233}
234
235fn maybe_set_recommended_school(store: &mut RamadanConfigStore, country: &str) {
236    let recommended_school = get_recommended_school(country);
237    let current_school = store.school.unwrap_or(DEFAULT_SCHOOL);
238
239    if !should_apply_recommended_school(current_school, recommended_school) {
240        return;
241    }
242
243    store.school = Some(recommended_school);
244}
245
246pub fn save_auto_detected_setup(location: &GeoLocation) -> Result<()> {
247    let mut store = load_store();
248    store.city = Some(location.city.clone());
249    store.country = Some(location.country.clone());
250    store.latitude = Some(location.latitude);
251    store.longitude = Some(location.longitude);
252    if !location.timezone.trim().is_empty() {
253        store.timezone = Some(location.timezone.clone());
254    }
255
256    maybe_set_recommended_method(&mut store, &location.country);
257    maybe_set_recommended_school(&mut store, &location.country);
258    save_store(&store)
259}
260
261pub fn apply_recommended_settings_if_unset(country: &str) -> Result<()> {
262    let mut store = load_store();
263    maybe_set_recommended_method(&mut store, country);
264    maybe_set_recommended_school(&mut store, country);
265    save_store(&store)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::{should_apply_recommended_method, should_apply_recommended_school};
271
272    #[test]
273    fn recommendation_guards_match_expected() {
274        assert!(should_apply_recommended_method(2, 1));
275        assert!(!should_apply_recommended_method(3, 1));
276        assert!(should_apply_recommended_school(0, 1));
277        assert!(!should_apply_recommended_school(1, 0));
278    }
279}