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}