ramadhan-cli-rust 0.1.0

Ramadan-first CLI for Sehar and Iftar timings in your terminal
Documentation
use std::process::ExitCode;

use clap::{ArgAction, Parser, Subcommand};
use reqwest::blocking::Client;

use crate::commands::config::{ConfigCommandOptions, config_command};
use crate::commands::ramadan::{RamadanCommandOptions, ramadan_command, to_json_error_payload};
use crate::ramadan_config::clear_ramadan_config;

#[derive(Debug, Parser)]
#[command(name = "ramadan-cli")]
#[command(about = "Ramadan CLI for Sehar and Iftar timings")]
#[command(
    long_about = "Ramadan-first CLI for Sehar and Iftar timings. Default run shows today's view."
)]
#[command(version, disable_version_flag = true)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Commands>,

    #[arg(
        index = 1,
        help = "City name (e.g. \"San Francisco\", \"sf\", \"Vancouver\", \"Lahore\")"
    )]
    pub city_arg: Option<String>,

    #[arg(short = 'c', long = "city", help = "City")]
    pub city_opt: Option<String>,

    #[arg(short = 'a', long = "all", action = ArgAction::SetTrue, help = "Show complete Ramadan month")]
    pub all: bool,

    #[arg(short = 'n', long = "number", value_parser = parse_roza_number, help = "Show a specific roza day (1-30)", value_name = "number")]
    pub number: Option<usize>,

    #[arg(short = 'p', long = "plain", action = ArgAction::SetTrue, help = "Plain text output")]
    pub plain: bool,

    #[arg(short = 'j', long = "json", action = ArgAction::SetTrue, help = "JSON output")]
    pub json: bool,

    #[arg(
        long = "first-roza-date",
        help = "Set and use a custom first roza date",
        value_name = "YYYY-MM-DD"
    )]
    pub first_roza_date: Option<String>,

    #[arg(long = "clear-first-roza-date", action = ArgAction::SetTrue, help = "Clear custom first roza date and use API Ramadan date")]
    pub clear_first_roza_date: bool,

    #[arg(short = 'v', long = "version", action = ArgAction::SetTrue, help = "Print version only")]
    pub version: bool,
}

#[derive(Debug, Subcommand)]
pub enum Commands {
    #[command(about = "Clear saved Ramadan CLI configuration")]
    Reset,
    #[command(about = "Configure saved Ramadan CLI settings (non-interactive)")]
    Config {
        #[arg(long, help = "Save city")]
        city: Option<String>,
        #[arg(long, help = "Save country")]
        country: Option<String>,
        #[arg(long, help = "Save latitude (-90 to 90)")]
        latitude: Option<String>,
        #[arg(long, help = "Save longitude (-180 to 180)")]
        longitude: Option<String>,
        #[arg(long, help = "Save calculation method (0-23)")]
        method: Option<String>,
        #[arg(long, help = "Save school (0=Shafi, 1=Hanafi)")]
        school: Option<String>,
        #[arg(long, help = "Save timezone (e.g., America/Los_Angeles)")]
        timezone: Option<String>,
        #[arg(long, action = ArgAction::SetTrue, help = "Show current configuration")]
        show: bool,
        #[arg(long, action = ArgAction::SetTrue, help = "Clear saved configuration")]
        clear: bool,
    },
}

fn parse_roza_number(value: &str) -> Result<usize, String> {
    let parsed = value
        .parse::<usize>()
        .map_err(|_| "Roza number must be between 1 and 30.".to_string())?;

    if !(1..=30).contains(&parsed) {
        return Err("Roza number must be between 1 and 30.".to_string());
    }

    Ok(parsed)
}

pub fn run() -> ExitCode {
    let cli = Cli::parse();

    if cli.version {
        println!("{}", env!("CARGO_PKG_VERSION"));
        return ExitCode::SUCCESS;
    }

    let client = match Client::builder().build() {
        Ok(client) => client,
        Err(error) => {
            eprintln!("Failed to initialize HTTP client: {error}");
            return ExitCode::from(1);
        }
    };

    match cli.command {
        Some(Commands::Reset) => match clear_ramadan_config() {
            Ok(()) => {
                println!("Configuration reset.");
                ExitCode::SUCCESS
            }
            Err(error) => {
                eprintln!("{error}");
                ExitCode::from(1)
            }
        },
        Some(Commands::Config {
            city,
            country,
            latitude,
            longitude,
            method,
            school,
            timezone,
            show,
            clear,
        }) => {
            let options = ConfigCommandOptions {
                city,
                country,
                latitude,
                longitude,
                method,
                school,
                timezone,
                show,
                clear,
            };

            match config_command(&options) {
                Ok(()) => ExitCode::SUCCESS,
                Err(error) => {
                    eprintln!("{error}");
                    ExitCode::from(1)
                }
            }
        }
        None => {
            let options = RamadanCommandOptions {
                city: cli.city_arg.or(cli.city_opt),
                all: cli.all,
                roza_number: cli.number,
                plain: cli.plain,
                json: cli.json,
                first_roza_date: cli.first_roza_date,
                clear_first_roza_date: cli.clear_first_roza_date,
            };

            match ramadan_command(&client, &options) {
                Ok(Some(output)) => {
                    println!(
                        "{}",
                        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
                    );
                    ExitCode::SUCCESS
                }
                Ok(None) => ExitCode::SUCCESS,
                Err(error) => {
                    if error.to_string() == "SETUP_CANCELLED" {
                        return ExitCode::SUCCESS;
                    }

                    if options.json {
                        let payload = to_json_error_payload(&error);
                        eprintln!(
                            "{}",
                            serde_json::to_string(&payload).unwrap_or_else(|_| {
                                "{\"ok\":false,\"error\":{\"code\":\"UNKNOWN_ERROR\",\"message\":\"unknown error\"}}".to_string()
                            })
                        );
                    } else {
                        eprintln!("{error}");
                    }

                    ExitCode::from(1)
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use super::{Cli, Commands};

    #[test]
    fn parses_version_flag() {
        let parsed = Cli::try_parse_from(["ramadan-cli", "-v"]).expect("should parse");
        assert!(parsed.version);
    }

    #[test]
    fn rejects_invalid_roza_number() {
        let parsed = Cli::try_parse_from(["ramadan-cli", "-n", "31"]);
        assert!(parsed.is_err());
    }

    #[test]
    fn parses_config_subcommand_show() {
        let parsed =
            Cli::try_parse_from(["ramadan-cli", "config", "--show"]).expect("should parse");
        match parsed.command {
            Some(Commands::Config { show, .. }) => assert!(show),
            _ => panic!("expected config subcommand"),
        }
    }
}