ramadhan-cli-rust 0.1.0

Ramadan-first CLI for Sehar and Iftar timings in your terminal
Documentation
use anyhow::{Result, anyhow};

use crate::ramadan_config::{
    StoredLocation, clear_ramadan_config, get_stored_first_roza_date, get_stored_location,
    get_stored_prayer_settings, set_stored_location, set_stored_method, set_stored_school,
    set_stored_timezone,
};

#[derive(Debug, Clone, Default)]
pub struct ConfigCommandOptions {
    pub city: Option<String>,
    pub country: Option<String>,
    pub latitude: Option<String>,
    pub longitude: Option<String>,
    pub method: Option<String>,
    pub school: Option<String>,
    pub timezone: Option<String>,
    pub show: bool,
    pub clear: bool,
}

#[derive(Debug, Clone, Default)]
pub struct ParsedConfigUpdates {
    pub city: Option<String>,
    pub country: Option<String>,
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
    pub method: Option<i64>,
    pub school: Option<i64>,
    pub timezone: Option<String>,
}

fn parse_optional_i64_in_range(
    value: Option<&str>,
    min: i64,
    max: i64,
    label: &str,
) -> Result<Option<i64>> {
    let Some(raw) = value else {
        return Ok(None);
    };

    let parsed = raw
        .parse::<i64>()
        .map_err(|_| anyhow!("Invalid {label}."))?;
    if parsed < min || parsed > max {
        return Err(anyhow!("Invalid {label}."));
    }

    Ok(Some(parsed))
}

fn parse_optional_f64_in_range(
    value: Option<&str>,
    min: f64,
    max: f64,
    label: &str,
) -> Result<Option<f64>> {
    let Some(raw) = value else {
        return Ok(None);
    };

    let parsed = raw
        .parse::<f64>()
        .map_err(|_| anyhow!("Invalid {label}."))?;
    if parsed < min || parsed > max {
        return Err(anyhow!("Invalid {label}."));
    }

    Ok(Some(parsed))
}

pub fn parse_config_updates(options: &ConfigCommandOptions) -> Result<ParsedConfigUpdates> {
    Ok(ParsedConfigUpdates {
        city: options
            .city
            .as_ref()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty()),
        country: options
            .country
            .as_ref()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty()),
        latitude: parse_optional_f64_in_range(
            options.latitude.as_deref(),
            -90.0,
            90.0,
            "latitude",
        )?,
        longitude: parse_optional_f64_in_range(
            options.longitude.as_deref(),
            -180.0,
            180.0,
            "longitude",
        )?,
        method: parse_optional_i64_in_range(options.method.as_deref(), 0, 23, "method")?,
        school: parse_optional_i64_in_range(options.school.as_deref(), 0, 1, "school")?,
        timezone: options
            .timezone
            .as_ref()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty()),
    })
}

pub fn merge_location_updates(
    current: &StoredLocation,
    updates: &ParsedConfigUpdates,
) -> StoredLocation {
    StoredLocation {
        city: updates.city.clone().or_else(|| current.city.clone()),
        country: updates.country.clone().or_else(|| current.country.clone()),
        latitude: updates.latitude.or(current.latitude),
        longitude: updates.longitude.or(current.longitude),
    }
}

fn print_current_config() {
    let location = get_stored_location();
    let settings = get_stored_prayer_settings();
    let first_roza_date = get_stored_first_roza_date();

    println!("Current configuration:");
    if let Some(city) = location.city {
        println!("  City: {city}");
    }
    if let Some(country) = location.country {
        println!("  Country: {country}");
    }
    if let Some(latitude) = location.latitude {
        println!("  Latitude: {latitude}");
    }
    if let Some(longitude) = location.longitude {
        println!("  Longitude: {longitude}");
    }
    println!("  Method: {}", settings.method);
    println!("  School: {}", settings.school);
    if let Some(timezone) = settings.timezone {
        println!("  Timezone: {timezone}");
    }
    if let Some(date) = first_roza_date {
        println!("  First Roza Date: {date}");
    }
}

fn has_config_update_flags(options: &ConfigCommandOptions) -> bool {
    options.city.is_some()
        || options.country.is_some()
        || options.latitude.is_some()
        || options.longitude.is_some()
        || options.method.is_some()
        || options.school.is_some()
        || options.timezone.is_some()
}

pub fn config_command(options: &ConfigCommandOptions) -> Result<()> {
    if options.clear {
        clear_ramadan_config()?;
        println!("Configuration cleared.");
        return Ok(());
    }

    if options.show {
        print_current_config();
        return Ok(());
    }

    if !has_config_update_flags(options) {
        println!("No config updates provided. Use `ramadan-cli config --show` to inspect.");
        return Ok(());
    }

    let updates = parse_config_updates(options)?;
    let current_location = get_stored_location();
    let next_location = merge_location_updates(&current_location, &updates);

    set_stored_location(&next_location)?;

    if let Some(method) = updates.method {
        set_stored_method(method)?;
    }
    if let Some(school) = updates.school {
        set_stored_school(school)?;
    }
    if let Some(timezone) = updates.timezone {
        set_stored_timezone(Some(&timezone))?;
    }

    println!("Configuration updated.");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{ConfigCommandOptions, merge_location_updates, parse_config_updates};

    #[test]
    fn parse_config_updates_validates_ranges() {
        let parsed = parse_config_updates(&ConfigCommandOptions {
            method: Some("2".to_string()),
            school: Some("1".to_string()),
            latitude: Some("37.7749".to_string()),
            longitude: Some("-122.4194".to_string()),
            ..ConfigCommandOptions::default()
        })
        .expect("valid updates");

        assert_eq!(parsed.method, Some(2));
        assert_eq!(parsed.school, Some(1));
        assert_eq!(parsed.latitude, Some(37.7749));
        assert_eq!(parsed.longitude, Some(-122.4194));
    }

    #[test]
    fn merge_location_updates_only_overrides_provided_fields() {
        let current = crate::ramadan_config::StoredLocation {
            city: Some("Lahore".to_string()),
            country: Some("Pakistan".to_string()),
            latitude: Some(31.5204),
            longitude: Some(74.3587),
        };

        let merged = merge_location_updates(
            &current,
            &super::ParsedConfigUpdates {
                city: Some("San Francisco".to_string()),
                ..super::ParsedConfigUpdates::default()
            },
        );

        assert_eq!(merged.city.as_deref(), Some("San Francisco"));
        assert_eq!(merged.country.as_deref(), Some("Pakistan"));
        assert_eq!(merged.latitude, Some(31.5204));
        assert_eq!(merged.longitude, Some(74.3587));
    }
}