ramadhan-cli-rust 0.1.0

Ramadan-first CLI for Sehar and Iftar timings in your terminal
Documentation
use std::fs;
use std::path::PathBuf;

use anyhow::{Result, anyhow};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};

use crate::geo::GeoLocation;
use crate::recommendations::{get_recommended_method, get_recommended_school};

const DEFAULT_METHOD: i64 = 2;
const DEFAULT_SCHOOL: i64 = 0;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RamadanConfigStore {
    latitude: Option<f64>,
    longitude: Option<f64>,
    city: Option<String>,
    country: Option<String>,
    method: Option<i64>,
    school: Option<i64>,
    timezone: Option<String>,
    first_roza_date: Option<String>,
    format24h: Option<bool>,
}

#[derive(Debug, Clone)]
pub struct StoredLocation {
    pub city: Option<String>,
    pub country: Option<String>,
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
}

#[derive(Debug, Clone)]
pub struct StoredPrayerSettings {
    pub method: i64,
    pub school: i64,
    pub timezone: Option<String>,
}

fn config_base_dir() -> PathBuf {
    if let Ok(path) = std::env::var("RAMADAN_CLI_CONFIG_DIR") {
        return PathBuf::from(path);
    }

    let is_test = std::env::var("VITEST").as_deref() == Ok("true")
        || std::env::var("NODE_ENV").as_deref() == Ok("test");
    if is_test {
        return PathBuf::from("/tmp");
    }

    if let Some(base) = BaseDirs::new() {
        return base.config_dir().to_path_buf();
    }

    PathBuf::from(".")
}

fn config_dir() -> PathBuf {
    config_base_dir().join("ramadan-cli")
}

fn config_file_path() -> PathBuf {
    config_dir().join("config.json")
}

fn default_store() -> RamadanConfigStore {
    RamadanConfigStore {
        method: Some(DEFAULT_METHOD),
        school: Some(DEFAULT_SCHOOL),
        format24h: Some(false),
        ..RamadanConfigStore::default()
    }
}

fn load_store() -> RamadanConfigStore {
    let path = config_file_path();
    let raw = fs::read_to_string(path);
    match raw {
        Ok(contents) => serde_json::from_str::<RamadanConfigStore>(&contents)
            .unwrap_or_else(|_| default_store()),
        Err(_) => default_store(),
    }
}

fn save_store(store: &RamadanConfigStore) -> Result<()> {
    let dir = config_dir();
    fs::create_dir_all(&dir)?;
    let json = serde_json::to_string_pretty(store)?;
    fs::write(config_file_path(), json)?;
    Ok(())
}

pub fn should_apply_recommended_method(current_method: i64, recommended_method: i64) -> bool {
    current_method == DEFAULT_METHOD || current_method == recommended_method
}

pub fn should_apply_recommended_school(current_school: i64, recommended_school: i64) -> bool {
    current_school == DEFAULT_SCHOOL || current_school == recommended_school
}

pub fn get_stored_location() -> StoredLocation {
    let store = load_store();
    StoredLocation {
        city: store.city,
        country: store.country,
        latitude: store.latitude,
        longitude: store.longitude,
    }
}

pub fn has_stored_location() -> bool {
    let location = get_stored_location();
    let has_city_country = location.city.is_some() && location.country.is_some();
    let has_coords = location.latitude.is_some() && location.longitude.is_some();
    has_city_country || has_coords
}

pub fn get_stored_prayer_settings() -> StoredPrayerSettings {
    let store = load_store();
    StoredPrayerSettings {
        method: store.method.unwrap_or(DEFAULT_METHOD),
        school: store.school.unwrap_or(DEFAULT_SCHOOL),
        timezone: store.timezone,
    }
}

pub fn set_stored_location(location: &StoredLocation) -> Result<()> {
    let mut store = load_store();

    if let Some(city) = &location.city {
        store.city = Some(city.clone());
    }
    if let Some(country) = &location.country {
        store.country = Some(country.clone());
    }
    if let Some(latitude) = location.latitude {
        store.latitude = Some(latitude);
    }
    if let Some(longitude) = location.longitude {
        store.longitude = Some(longitude);
    }

    save_store(&store)
}

pub fn set_stored_timezone(timezone: Option<&str>) -> Result<()> {
    let mut store = load_store();
    if let Some(timezone) = timezone {
        if !timezone.trim().is_empty() {
            store.timezone = Some(timezone.trim().to_string());
        }
    }
    save_store(&store)
}

pub fn set_stored_method(method: i64) -> Result<()> {
    let mut store = load_store();
    store.method = Some(method);
    save_store(&store)
}

pub fn set_stored_school(school: i64) -> Result<()> {
    let mut store = load_store();
    store.school = Some(school);
    save_store(&store)
}

pub fn get_stored_first_roza_date() -> Option<String> {
    let store = load_store();
    store.first_roza_date
}

fn is_valid_iso_date(value: &str) -> bool {
    let parts: Vec<&str> = value.split('-').collect();
    if parts.len() != 3 {
        return false;
    }

    let year = parts[0].parse::<i32>().ok();
    let month = parts[1].parse::<u32>().ok();
    let day = parts[2].parse::<u32>().ok();

    match (year, month, day) {
        (Some(y), Some(m), Some(d)) => chrono::NaiveDate::from_ymd_opt(y, m, d).is_some(),
        _ => false,
    }
}

pub fn set_stored_first_roza_date(first_roza_date: &str) -> Result<()> {
    if !is_valid_iso_date(first_roza_date) {
        return Err(anyhow!("Invalid first roza date. Use YYYY-MM-DD."));
    }

    let mut store = load_store();
    store.first_roza_date = Some(first_roza_date.to_string());
    save_store(&store)
}

pub fn clear_stored_first_roza_date() -> Result<()> {
    let mut store = load_store();
    store.first_roza_date = None;
    save_store(&store)
}

pub fn clear_ramadan_config() -> Result<()> {
    let config_path = config_file_path();
    if config_path.exists() {
        fs::remove_file(config_path)?;
    }

    // Best-effort cleanup of legacy app config location.
    let legacy = config_base_dir().join("azaan").join("config.json");
    if legacy.exists() {
        let _ = fs::remove_file(legacy);
    }

    Ok(())
}

fn maybe_set_recommended_method(store: &mut RamadanConfigStore, country: &str) {
    let Some(recommended_method) = get_recommended_method(country) else {
        return;
    };

    let current_method = store.method.unwrap_or(DEFAULT_METHOD);
    if !should_apply_recommended_method(current_method, recommended_method) {
        return;
    }

    store.method = Some(recommended_method);
}

fn maybe_set_recommended_school(store: &mut RamadanConfigStore, country: &str) {
    let recommended_school = get_recommended_school(country);
    let current_school = store.school.unwrap_or(DEFAULT_SCHOOL);

    if !should_apply_recommended_school(current_school, recommended_school) {
        return;
    }

    store.school = Some(recommended_school);
}

pub fn save_auto_detected_setup(location: &GeoLocation) -> Result<()> {
    let mut store = load_store();
    store.city = Some(location.city.clone());
    store.country = Some(location.country.clone());
    store.latitude = Some(location.latitude);
    store.longitude = Some(location.longitude);
    if !location.timezone.trim().is_empty() {
        store.timezone = Some(location.timezone.clone());
    }

    maybe_set_recommended_method(&mut store, &location.country);
    maybe_set_recommended_school(&mut store, &location.country);
    save_store(&store)
}

pub fn apply_recommended_settings_if_unset(country: &str) -> Result<()> {
    let mut store = load_store();
    maybe_set_recommended_method(&mut store, country);
    maybe_set_recommended_school(&mut store, country);
    save_store(&store)
}

#[cfg(test)]
mod tests {
    use super::{should_apply_recommended_method, should_apply_recommended_school};

    #[test]
    fn recommendation_guards_match_expected() {
        assert!(should_apply_recommended_method(2, 1));
        assert!(!should_apply_recommended_method(3, 1));
        assert!(should_apply_recommended_school(0, 1));
        assert!(!should_apply_recommended_school(1, 0));
    }
}