Skip to main content

ramadan_cli/
setup.rs

1use anyhow::Result;
2use dialoguer::{Input, Select, theme::ColorfulTheme};
3use reqwest::blocking::Client;
4
5use crate::geo::{GeoLocation, guess_city_country, guess_location};
6use crate::ramadan_config::{
7    StoredLocation, set_stored_location, set_stored_method, set_stored_school, set_stored_timezone,
8};
9use crate::recommendations::{get_recommended_method, get_recommended_school};
10use crate::ui::theme::{MOON_EMOJI, ramadan_green};
11
12#[derive(Debug, Clone)]
13pub struct SelectOption<TValue> {
14    pub value: TValue,
15    pub label: String,
16    pub hint: Option<String>,
17}
18
19type TimezoneChoice = &'static str;
20
21const SCHOOL_SHAFI: i64 = 0;
22const SCHOOL_HANAFI: i64 = 1;
23
24fn method_options() -> Vec<SelectOption<i64>> {
25    vec![
26        SelectOption {
27            value: 0,
28            label: "Jafari (Shia Ithna-Ashari)".to_string(),
29            hint: None,
30        },
31        SelectOption {
32            value: 1,
33            label: "Karachi (Pakistan)".to_string(),
34            hint: None,
35        },
36        SelectOption {
37            value: 2,
38            label: "ISNA (North America)".to_string(),
39            hint: None,
40        },
41        SelectOption {
42            value: 3,
43            label: "MWL (Muslim World League)".to_string(),
44            hint: None,
45        },
46        SelectOption {
47            value: 4,
48            label: "Makkah (Umm al-Qura)".to_string(),
49            hint: None,
50        },
51        SelectOption {
52            value: 5,
53            label: "Egypt".to_string(),
54            hint: None,
55        },
56        SelectOption {
57            value: 7,
58            label: "Tehran (Shia)".to_string(),
59            hint: None,
60        },
61        SelectOption {
62            value: 8,
63            label: "Gulf Region".to_string(),
64            hint: None,
65        },
66        SelectOption {
67            value: 9,
68            label: "Kuwait".to_string(),
69            hint: None,
70        },
71        SelectOption {
72            value: 10,
73            label: "Qatar".to_string(),
74            hint: None,
75        },
76        SelectOption {
77            value: 11,
78            label: "Singapore".to_string(),
79            hint: None,
80        },
81        SelectOption {
82            value: 12,
83            label: "France".to_string(),
84            hint: None,
85        },
86        SelectOption {
87            value: 13,
88            label: "Turkey".to_string(),
89            hint: None,
90        },
91        SelectOption {
92            value: 14,
93            label: "Russia".to_string(),
94            hint: None,
95        },
96        SelectOption {
97            value: 15,
98            label: "Moonsighting Committee".to_string(),
99            hint: None,
100        },
101        SelectOption {
102            value: 16,
103            label: "Dubai".to_string(),
104            hint: None,
105        },
106        SelectOption {
107            value: 17,
108            label: "Malaysia (JAKIM)".to_string(),
109            hint: None,
110        },
111        SelectOption {
112            value: 18,
113            label: "Tunisia".to_string(),
114            hint: None,
115        },
116        SelectOption {
117            value: 19,
118            label: "Algeria".to_string(),
119            hint: None,
120        },
121        SelectOption {
122            value: 20,
123            label: "Indonesia".to_string(),
124            hint: None,
125        },
126        SelectOption {
127            value: 21,
128            label: "Morocco".to_string(),
129            hint: None,
130        },
131        SelectOption {
132            value: 22,
133            label: "Portugal".to_string(),
134            hint: None,
135        },
136        SelectOption {
137            value: 23,
138            label: "Jordan".to_string(),
139            hint: None,
140        },
141    ]
142}
143
144fn find_method_label(method: i64) -> String {
145    method_options()
146        .into_iter()
147        .find(|option| option.value == method)
148        .map(|option| option.label)
149        .unwrap_or_else(|| format!("Method {method}"))
150}
151
152pub fn get_method_options(recommended_method: Option<i64>) -> Vec<SelectOption<i64>> {
153    let all = method_options();
154    let Some(recommended) = recommended_method else {
155        return all;
156    };
157
158    let mut options = vec![SelectOption {
159        value: recommended,
160        label: format!("{} (Recommended)", find_method_label(recommended)),
161        hint: Some("Based on your country".to_string()),
162    }];
163
164    options.extend(all.into_iter().filter(|entry| entry.value != recommended));
165    options
166}
167
168pub fn get_school_options(recommended_school: i64) -> Vec<SelectOption<i64>> {
169    if recommended_school == SCHOOL_HANAFI {
170        return vec![
171            SelectOption {
172                value: SCHOOL_HANAFI,
173                label: "Hanafi (Recommended)".to_string(),
174                hint: Some("Later Asr timing".to_string()),
175            },
176            SelectOption {
177                value: SCHOOL_SHAFI,
178                label: "Shafi".to_string(),
179                hint: Some("Standard Asr timing".to_string()),
180            },
181        ];
182    }
183
184    vec![
185        SelectOption {
186            value: SCHOOL_SHAFI,
187            label: "Shafi (Recommended)".to_string(),
188            hint: Some("Standard Asr timing".to_string()),
189        },
190        SelectOption {
191            value: SCHOOL_HANAFI,
192            label: "Hanafi".to_string(),
193            hint: Some("Later Asr timing".to_string()),
194        },
195    ]
196}
197
198fn normalize(value: &str) -> String {
199    value.trim().to_ascii_lowercase()
200}
201
202fn city_country_matches_guess(city: &str, country: &str, guess: &GeoLocation) -> bool {
203    normalize(city) == normalize(&guess.city) && normalize(country) == normalize(&guess.country)
204}
205
206fn resolve_detected_details(
207    client: &Client,
208    city: &str,
209    country: &str,
210    ip_guess: Option<&GeoLocation>,
211) -> (Option<f64>, Option<f64>, Option<String>) {
212    if let Some(geocoded) = guess_city_country(client, &format!("{city}, {country}")) {
213        return (
214            Some(geocoded.latitude),
215            Some(geocoded.longitude),
216            geocoded.timezone,
217        );
218    }
219
220    if let Some(guess) = ip_guess {
221        if city_country_matches_guess(city, country, guess) {
222            return (
223                Some(guess.latitude),
224                Some(guess.longitude),
225                Some(guess.timezone.clone()),
226            );
227        }
228    }
229
230    (None, None, None)
231}
232
233pub fn can_prompt_interactively() -> bool {
234    use std::io::IsTerminal;
235
236    std::io::stdin().is_terminal()
237        && std::io::stdout().is_terminal()
238        && std::env::var("CI").as_deref() != Ok("true")
239}
240
241pub fn run_first_run_setup(client: &Client) -> Result<bool> {
242    println!(
243        "{}",
244        ramadan_green(&format!("{MOON_EMOJI} Ramadan CLI Setup"))
245    );
246
247    let ip_guess = guess_location(client);
248    if let Some(guess) = &ip_guess {
249        println!("Detected: {}, {}", guess.city, guess.country);
250    } else {
251        println!("Could not detect location");
252    }
253
254    let theme = ColorfulTheme::default();
255
256    let city = Input::<String>::with_theme(&theme)
257        .with_prompt("Enter your city")
258        .with_initial_text(
259            ip_guess
260                .as_ref()
261                .map(|g| g.city.clone())
262                .unwrap_or_default(),
263        )
264        .interact_text()?;
265
266    let country = Input::<String>::with_theme(&theme)
267        .with_prompt("Enter your country")
268        .with_initial_text(
269            ip_guess
270                .as_ref()
271                .map(|g| g.country.clone())
272                .unwrap_or_default(),
273        )
274        .interact_text()?;
275
276    let city = city.trim().to_string();
277    let country = country.trim().to_string();
278    if city.is_empty() || country.is_empty() {
279        eprintln!("City and country are required.");
280        return Ok(false);
281    }
282
283    let (latitude, longitude, detected_timezone) =
284        resolve_detected_details(client, &city, &country, ip_guess.as_ref());
285
286    let recommended_method = get_recommended_method(&country);
287    let method_options = get_method_options(recommended_method);
288    let method_labels: Vec<String> = method_options
289        .iter()
290        .map(|option| option.label.clone())
291        .collect();
292    let method_index = Select::with_theme(&theme)
293        .with_prompt("Select calculation method")
294        .items(&method_labels)
295        .default(0)
296        .interact()?;
297    let method = method_options[method_index].value;
298
299    let recommended_school = get_recommended_school(&country);
300    let school_options = get_school_options(recommended_school);
301    let school_labels: Vec<String> = school_options
302        .iter()
303        .map(|option| option.label.clone())
304        .collect();
305    let school_index = Select::with_theme(&theme)
306        .with_prompt("Select Asr school")
307        .items(&school_labels)
308        .default(0)
309        .interact()?;
310    let school = school_options[school_index].value;
311
312    let timezone_options: Vec<(TimezoneChoice, String)> = if let Some(tz) = &detected_timezone {
313        vec![
314            ("detected", format!("Use detected timezone ({tz})")),
315            ("custom", "Set custom timezone".to_string()),
316            ("skip", "Do not set timezone override".to_string()),
317        ]
318    } else {
319        vec![
320            ("custom", "Set custom timezone".to_string()),
321            ("skip", "Do not set timezone override".to_string()),
322        ]
323    };
324
325    let timezone_labels: Vec<String> = timezone_options
326        .iter()
327        .map(|(_, label)| label.clone())
328        .collect();
329    let timezone_index = Select::with_theme(&theme)
330        .with_prompt("Timezone preference")
331        .items(&timezone_labels)
332        .default(0)
333        .interact()?;
334
335    let timezone_choice = timezone_options[timezone_index].0;
336    let timezone = match timezone_choice {
337        "detected" => detected_timezone,
338        "custom" => {
339            let input = Input::<String>::with_theme(&theme)
340                .with_prompt("Enter timezone")
341                .with_initial_text(detected_timezone.clone().unwrap_or_default())
342                .interact_text()?;
343            let trimmed = input.trim().to_string();
344            if trimmed.is_empty() {
345                None
346            } else {
347                Some(trimmed)
348            }
349        }
350        _ => None,
351    };
352
353    set_stored_location(&StoredLocation {
354        city: Some(city),
355        country: Some(country),
356        latitude,
357        longitude,
358    })?;
359    set_stored_method(method)?;
360    set_stored_school(school)?;
361    set_stored_timezone(timezone.as_deref())?;
362
363    println!(
364        "{}",
365        ramadan_green(&format!("{MOON_EMOJI} Setup complete."))
366    );
367    Ok(true)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::{get_method_options, get_school_options};
373
374    #[test]
375    fn recommended_method_is_first_without_duplicates() {
376        let options = get_method_options(Some(1));
377        assert_eq!(options.first().map(|entry| entry.value), Some(1));
378        assert_eq!(options.iter().filter(|entry| entry.value == 1).count(), 1);
379    }
380
381    #[test]
382    fn school_order_follows_recommendation() {
383        let hanafi = get_school_options(1);
384        assert_eq!(hanafi[0].value, 1);
385
386        let shafi = get_school_options(0);
387        assert_eq!(shafi[0].value, 0);
388    }
389
390    #[test]
391    fn default_method_list_is_populated() {
392        let options = get_method_options(None);
393        assert!(options.len() > 10);
394        assert_eq!(options[0].value, 0);
395    }
396}