ramadhan-cli-rust 0.1.0

Ramadan-first CLI for Sehar and Iftar timings in your terminal
Documentation
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);
    }
}