Skip to main content

ramadan_cli/
cli.rs

1use std::process::ExitCode;
2
3use clap::{ArgAction, Parser, Subcommand};
4use reqwest::blocking::Client;
5
6use crate::commands::config::{ConfigCommandOptions, config_command};
7use crate::commands::ramadan::{RamadanCommandOptions, ramadan_command, to_json_error_payload};
8use crate::ramadan_config::clear_ramadan_config;
9
10#[derive(Debug, Parser)]
11#[command(name = "ramadan-cli")]
12#[command(about = "Ramadan CLI for Sehar and Iftar timings")]
13#[command(
14    long_about = "Ramadan-first CLI for Sehar and Iftar timings. Default run shows today's view."
15)]
16#[command(version, disable_version_flag = true)]
17pub struct Cli {
18    #[command(subcommand)]
19    pub command: Option<Commands>,
20
21    #[arg(
22        index = 1,
23        help = "City name (e.g. \"San Francisco\", \"sf\", \"Vancouver\", \"Lahore\")"
24    )]
25    pub city_arg: Option<String>,
26
27    #[arg(short = 'c', long = "city", help = "City")]
28    pub city_opt: Option<String>,
29
30    #[arg(short = 'a', long = "all", action = ArgAction::SetTrue, help = "Show complete Ramadan month")]
31    pub all: bool,
32
33    #[arg(short = 'n', long = "number", value_parser = parse_roza_number, help = "Show a specific roza day (1-30)", value_name = "number")]
34    pub number: Option<usize>,
35
36    #[arg(short = 'p', long = "plain", action = ArgAction::SetTrue, help = "Plain text output")]
37    pub plain: bool,
38
39    #[arg(short = 'j', long = "json", action = ArgAction::SetTrue, help = "JSON output")]
40    pub json: bool,
41
42    #[arg(
43        long = "first-roza-date",
44        help = "Set and use a custom first roza date",
45        value_name = "YYYY-MM-DD"
46    )]
47    pub first_roza_date: Option<String>,
48
49    #[arg(long = "clear-first-roza-date", action = ArgAction::SetTrue, help = "Clear custom first roza date and use API Ramadan date")]
50    pub clear_first_roza_date: bool,
51
52    #[arg(short = 'v', long = "version", action = ArgAction::SetTrue, help = "Print version only")]
53    pub version: bool,
54}
55
56#[derive(Debug, Subcommand)]
57pub enum Commands {
58    #[command(about = "Clear saved Ramadan CLI configuration")]
59    Reset,
60    #[command(about = "Configure saved Ramadan CLI settings (non-interactive)")]
61    Config {
62        #[arg(long, help = "Save city")]
63        city: Option<String>,
64        #[arg(long, help = "Save country")]
65        country: Option<String>,
66        #[arg(long, help = "Save latitude (-90 to 90)")]
67        latitude: Option<String>,
68        #[arg(long, help = "Save longitude (-180 to 180)")]
69        longitude: Option<String>,
70        #[arg(long, help = "Save calculation method (0-23)")]
71        method: Option<String>,
72        #[arg(long, help = "Save school (0=Shafi, 1=Hanafi)")]
73        school: Option<String>,
74        #[arg(long, help = "Save timezone (e.g., America/Los_Angeles)")]
75        timezone: Option<String>,
76        #[arg(long, action = ArgAction::SetTrue, help = "Show current configuration")]
77        show: bool,
78        #[arg(long, action = ArgAction::SetTrue, help = "Clear saved configuration")]
79        clear: bool,
80    },
81}
82
83fn parse_roza_number(value: &str) -> Result<usize, String> {
84    let parsed = value
85        .parse::<usize>()
86        .map_err(|_| "Roza number must be between 1 and 30.".to_string())?;
87
88    if !(1..=30).contains(&parsed) {
89        return Err("Roza number must be between 1 and 30.".to_string());
90    }
91
92    Ok(parsed)
93}
94
95pub fn run() -> ExitCode {
96    let cli = Cli::parse();
97
98    if cli.version {
99        println!("{}", env!("CARGO_PKG_VERSION"));
100        return ExitCode::SUCCESS;
101    }
102
103    let client = match Client::builder().build() {
104        Ok(client) => client,
105        Err(error) => {
106            eprintln!("Failed to initialize HTTP client: {error}");
107            return ExitCode::from(1);
108        }
109    };
110
111    match cli.command {
112        Some(Commands::Reset) => match clear_ramadan_config() {
113            Ok(()) => {
114                println!("Configuration reset.");
115                ExitCode::SUCCESS
116            }
117            Err(error) => {
118                eprintln!("{error}");
119                ExitCode::from(1)
120            }
121        },
122        Some(Commands::Config {
123            city,
124            country,
125            latitude,
126            longitude,
127            method,
128            school,
129            timezone,
130            show,
131            clear,
132        }) => {
133            let options = ConfigCommandOptions {
134                city,
135                country,
136                latitude,
137                longitude,
138                method,
139                school,
140                timezone,
141                show,
142                clear,
143            };
144
145            match config_command(&options) {
146                Ok(()) => ExitCode::SUCCESS,
147                Err(error) => {
148                    eprintln!("{error}");
149                    ExitCode::from(1)
150                }
151            }
152        }
153        None => {
154            let options = RamadanCommandOptions {
155                city: cli.city_arg.or(cli.city_opt),
156                all: cli.all,
157                roza_number: cli.number,
158                plain: cli.plain,
159                json: cli.json,
160                first_roza_date: cli.first_roza_date,
161                clear_first_roza_date: cli.clear_first_roza_date,
162            };
163
164            match ramadan_command(&client, &options) {
165                Ok(Some(output)) => {
166                    println!(
167                        "{}",
168                        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
169                    );
170                    ExitCode::SUCCESS
171                }
172                Ok(None) => ExitCode::SUCCESS,
173                Err(error) => {
174                    if error.to_string() == "SETUP_CANCELLED" {
175                        return ExitCode::SUCCESS;
176                    }
177
178                    if options.json {
179                        let payload = to_json_error_payload(&error);
180                        eprintln!(
181                            "{}",
182                            serde_json::to_string(&payload).unwrap_or_else(|_| {
183                                "{\"ok\":false,\"error\":{\"code\":\"UNKNOWN_ERROR\",\"message\":\"unknown error\"}}".to_string()
184                            })
185                        );
186                    } else {
187                        eprintln!("{error}");
188                    }
189
190                    ExitCode::from(1)
191                }
192            }
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use clap::Parser;
200
201    use super::{Cli, Commands};
202
203    #[test]
204    fn parses_version_flag() {
205        let parsed = Cli::try_parse_from(["ramadan-cli", "-v"]).expect("should parse");
206        assert!(parsed.version);
207    }
208
209    #[test]
210    fn rejects_invalid_roza_number() {
211        let parsed = Cli::try_parse_from(["ramadan-cli", "-n", "31"]);
212        assert!(parsed.is_err());
213    }
214
215    #[test]
216    fn parses_config_subcommand_show() {
217        let parsed =
218            Cli::try_parse_from(["ramadan-cli", "config", "--show"]).expect("should parse");
219        match parsed.command {
220            Some(Commands::Config { show, .. }) => assert!(show),
221            _ => panic!("expected config subcommand"),
222        }
223    }
224}