use anyhow::Result;
use dialoguer::{Input, Select, theme::ColorfulTheme};
use reqwest::blocking::Client;
use crate::geo::{GeoLocation, guess_city_country, guess_location};
use crate::ramadan_config::{
StoredLocation, set_stored_location, set_stored_method, set_stored_school, set_stored_timezone,
};
use crate::recommendations::{get_recommended_method, get_recommended_school};
use crate::ui::theme::{MOON_EMOJI, ramadan_green};
#[derive(Debug, Clone)]
pub struct SelectOption<TValue> {
pub value: TValue,
pub label: String,
pub hint: Option<String>,
}
type TimezoneChoice = &'static str;
const SCHOOL_SHAFI: i64 = 0;
const SCHOOL_HANAFI: i64 = 1;
fn method_options() -> Vec<SelectOption<i64>> {
vec![
SelectOption {
value: 0,
label: "Jafari (Shia Ithna-Ashari)".to_string(),
hint: None,
},
SelectOption {
value: 1,
label: "Karachi (Pakistan)".to_string(),
hint: None,
},
SelectOption {
value: 2,
label: "ISNA (North America)".to_string(),
hint: None,
},
SelectOption {
value: 3,
label: "MWL (Muslim World League)".to_string(),
hint: None,
},
SelectOption {
value: 4,
label: "Makkah (Umm al-Qura)".to_string(),
hint: None,
},
SelectOption {
value: 5,
label: "Egypt".to_string(),
hint: None,
},
SelectOption {
value: 7,
label: "Tehran (Shia)".to_string(),
hint: None,
},
SelectOption {
value: 8,
label: "Gulf Region".to_string(),
hint: None,
},
SelectOption {
value: 9,
label: "Kuwait".to_string(),
hint: None,
},
SelectOption {
value: 10,
label: "Qatar".to_string(),
hint: None,
},
SelectOption {
value: 11,
label: "Singapore".to_string(),
hint: None,
},
SelectOption {
value: 12,
label: "France".to_string(),
hint: None,
},
SelectOption {
value: 13,
label: "Turkey".to_string(),
hint: None,
},
SelectOption {
value: 14,
label: "Russia".to_string(),
hint: None,
},
SelectOption {
value: 15,
label: "Moonsighting Committee".to_string(),
hint: None,
},
SelectOption {
value: 16,
label: "Dubai".to_string(),
hint: None,
},
SelectOption {
value: 17,
label: "Malaysia (JAKIM)".to_string(),
hint: None,
},
SelectOption {
value: 18,
label: "Tunisia".to_string(),
hint: None,
},
SelectOption {
value: 19,
label: "Algeria".to_string(),
hint: None,
},
SelectOption {
value: 20,
label: "Indonesia".to_string(),
hint: None,
},
SelectOption {
value: 21,
label: "Morocco".to_string(),
hint: None,
},
SelectOption {
value: 22,
label: "Portugal".to_string(),
hint: None,
},
SelectOption {
value: 23,
label: "Jordan".to_string(),
hint: None,
},
]
}
fn find_method_label(method: i64) -> String {
method_options()
.into_iter()
.find(|option| option.value == method)
.map(|option| option.label)
.unwrap_or_else(|| format!("Method {method}"))
}
pub fn get_method_options(recommended_method: Option<i64>) -> Vec<SelectOption<i64>> {
let all = method_options();
let Some(recommended) = recommended_method else {
return all;
};
let mut options = vec![SelectOption {
value: recommended,
label: format!("{} (Recommended)", find_method_label(recommended)),
hint: Some("Based on your country".to_string()),
}];
options.extend(all.into_iter().filter(|entry| entry.value != recommended));
options
}
pub fn get_school_options(recommended_school: i64) -> Vec<SelectOption<i64>> {
if recommended_school == SCHOOL_HANAFI {
return vec![
SelectOption {
value: SCHOOL_HANAFI,
label: "Hanafi (Recommended)".to_string(),
hint: Some("Later Asr timing".to_string()),
},
SelectOption {
value: SCHOOL_SHAFI,
label: "Shafi".to_string(),
hint: Some("Standard Asr timing".to_string()),
},
];
}
vec![
SelectOption {
value: SCHOOL_SHAFI,
label: "Shafi (Recommended)".to_string(),
hint: Some("Standard Asr timing".to_string()),
},
SelectOption {
value: SCHOOL_HANAFI,
label: "Hanafi".to_string(),
hint: Some("Later Asr timing".to_string()),
},
]
}
fn normalize(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn city_country_matches_guess(city: &str, country: &str, guess: &GeoLocation) -> bool {
normalize(city) == normalize(&guess.city) && normalize(country) == normalize(&guess.country)
}
fn resolve_detected_details(
client: &Client,
city: &str,
country: &str,
ip_guess: Option<&GeoLocation>,
) -> (Option<f64>, Option<f64>, Option<String>) {
if let Some(geocoded) = guess_city_country(client, &format!("{city}, {country}")) {
return (
Some(geocoded.latitude),
Some(geocoded.longitude),
geocoded.timezone,
);
}
if let Some(guess) = ip_guess {
if city_country_matches_guess(city, country, guess) {
return (
Some(guess.latitude),
Some(guess.longitude),
Some(guess.timezone.clone()),
);
}
}
(None, None, None)
}
pub fn can_prompt_interactively() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var("CI").as_deref() != Ok("true")
}
pub fn run_first_run_setup(client: &Client) -> Result<bool> {
println!(
"{}",
ramadan_green(&format!("{MOON_EMOJI} Ramadan CLI Setup"))
);
let ip_guess = guess_location(client);
if let Some(guess) = &ip_guess {
println!("Detected: {}, {}", guess.city, guess.country);
} else {
println!("Could not detect location");
}
let theme = ColorfulTheme::default();
let city = Input::<String>::with_theme(&theme)
.with_prompt("Enter your city")
.with_initial_text(
ip_guess
.as_ref()
.map(|g| g.city.clone())
.unwrap_or_default(),
)
.interact_text()?;
let country = Input::<String>::with_theme(&theme)
.with_prompt("Enter your country")
.with_initial_text(
ip_guess
.as_ref()
.map(|g| g.country.clone())
.unwrap_or_default(),
)
.interact_text()?;
let city = city.trim().to_string();
let country = country.trim().to_string();
if city.is_empty() || country.is_empty() {
eprintln!("City and country are required.");
return Ok(false);
}
let (latitude, longitude, detected_timezone) =
resolve_detected_details(client, &city, &country, ip_guess.as_ref());
let recommended_method = get_recommended_method(&country);
let method_options = get_method_options(recommended_method);
let method_labels: Vec<String> = method_options
.iter()
.map(|option| option.label.clone())
.collect();
let method_index = Select::with_theme(&theme)
.with_prompt("Select calculation method")
.items(&method_labels)
.default(0)
.interact()?;
let method = method_options[method_index].value;
let recommended_school = get_recommended_school(&country);
let school_options = get_school_options(recommended_school);
let school_labels: Vec<String> = school_options
.iter()
.map(|option| option.label.clone())
.collect();
let school_index = Select::with_theme(&theme)
.with_prompt("Select Asr school")
.items(&school_labels)
.default(0)
.interact()?;
let school = school_options[school_index].value;
let timezone_options: Vec<(TimezoneChoice, String)> = if let Some(tz) = &detected_timezone {
vec![
("detected", format!("Use detected timezone ({tz})")),
("custom", "Set custom timezone".to_string()),
("skip", "Do not set timezone override".to_string()),
]
} else {
vec![
("custom", "Set custom timezone".to_string()),
("skip", "Do not set timezone override".to_string()),
]
};
let timezone_labels: Vec<String> = timezone_options
.iter()
.map(|(_, label)| label.clone())
.collect();
let timezone_index = Select::with_theme(&theme)
.with_prompt("Timezone preference")
.items(&timezone_labels)
.default(0)
.interact()?;
let timezone_choice = timezone_options[timezone_index].0;
let timezone = match timezone_choice {
"detected" => detected_timezone,
"custom" => {
let input = Input::<String>::with_theme(&theme)
.with_prompt("Enter timezone")
.with_initial_text(detected_timezone.clone().unwrap_or_default())
.interact_text()?;
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
_ => None,
};
set_stored_location(&StoredLocation {
city: Some(city),
country: Some(country),
latitude,
longitude,
})?;
set_stored_method(method)?;
set_stored_school(school)?;
set_stored_timezone(timezone.as_deref())?;
println!(
"{}",
ramadan_green(&format!("{MOON_EMOJI} Setup complete."))
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::{get_method_options, get_school_options};
#[test]
fn recommended_method_is_first_without_duplicates() {
let options = get_method_options(Some(1));
assert_eq!(options.first().map(|entry| entry.value), Some(1));
assert_eq!(options.iter().filter(|entry| entry.value == 1).count(), 1);
}
#[test]
fn school_order_follows_recommendation() {
let hanafi = get_school_options(1);
assert_eq!(hanafi[0].value, 1);
let shafi = get_school_options(0);
assert_eq!(shafi[0].value, 0);
}
#[test]
fn default_method_list_is_populated() {
let options = get_method_options(None);
assert!(options.len() > 10);
assert_eq!(options[0].value, 0);
}
}