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}