1use std::collections::HashMap;
2
3use anyhow::{Result, anyhow};
4use chrono::{Datelike, NaiveDate, Timelike};
5use chrono_tz::Tz;
6use reqwest::blocking::Client;
7use serde::Serialize;
8
9use crate::api::{
10 FetchByAddressOptions, FetchByCityOptions, FetchByCoordsOptions,
11 FetchHijriCalendarByAddressOptions, FetchHijriCalendarByCityOptions, PrayerData,
12 fetch_hijri_calendar_by_address, fetch_hijri_calendar_by_city, fetch_timings_by_address,
13 fetch_timings_by_city, fetch_timings_by_coords,
14};
15use crate::geo::{GeoLocation, guess_city_country, guess_location};
16use crate::ramadan_config::{
17 clear_stored_first_roza_date, get_stored_first_roza_date, get_stored_location,
18 get_stored_prayer_settings, has_stored_location, save_auto_detected_setup,
19 set_stored_first_roza_date, should_apply_recommended_method, should_apply_recommended_school,
20};
21use crate::recommendations::{get_recommended_method, get_recommended_school};
22use crate::setup::{can_prompt_interactively, run_first_run_setup};
23use crate::ui::banner::get_banner;
24use crate::ui::theme::ramadan_green;
25
26#[derive(Debug, Clone, Default)]
27pub struct RamadanCommandOptions {
28 pub city: Option<String>,
29 pub all: bool,
30 pub roza_number: Option<usize>,
31 pub plain: bool,
32 pub json: bool,
33 pub first_roza_date: Option<String>,
34 pub clear_first_roza_date: bool,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct RamadanRow {
39 pub roza: usize,
40 pub sehar: String,
41 pub iftar: String,
42 pub date: String,
43 pub hijri: String,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct RamadanOutput {
48 pub mode: String,
49 pub location: String,
50 #[serde(rename = "hijriYear")]
51 pub hijri_year: i64,
52 pub rows: Vec<RamadanRow>,
53}
54
55#[derive(Debug, Serialize)]
56pub struct JsonErrorPayload {
57 ok: bool,
58 error: JsonError,
59}
60
61#[derive(Debug, Serialize)]
62struct JsonError {
63 code: String,
64 message: String,
65}
66
67#[derive(Debug, Clone)]
68struct HighlightState {
69 current: String,
70 next: String,
71 countdown: String,
72}
73
74#[derive(Debug, Copy, Clone)]
75enum RowAnnotationKind {
76 Current,
77 Next,
78}
79
80#[derive(Debug, Clone)]
81struct RamadanQuery {
82 address: String,
83 city: Option<String>,
84 country: Option<String>,
85 latitude: Option<f64>,
86 longitude: Option<f64>,
87 method: Option<i64>,
88 school: Option<i64>,
89 timezone: Option<String>,
90}
91
92const DAY_MS: i64 = 24 * 60 * 60 * 1000;
93const MINUTES_IN_DAY: i64 = 24 * 60;
94
95pub fn normalize_city_alias(city: &str) -> String {
96 let trimmed = city.trim();
97 if trimmed.eq_ignore_ascii_case("sf") {
98 return "San Francisco".to_string();
99 }
100
101 trimmed.to_string()
102}
103
104pub fn to_12_hour_time(value: &str) -> String {
105 let clean_value = value.split_whitespace().next().unwrap_or(value);
106 let mut parts = clean_value.split(':');
107 let hour = parts.next().and_then(|p| p.parse::<i64>().ok());
108 let minute = parts.next().and_then(|p| p.parse::<i64>().ok());
109
110 let (Some(hour), Some(minute)) = (hour, minute) else {
111 return clean_value.to_string();
112 };
113
114 if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) {
115 return clean_value.to_string();
116 }
117
118 let period = if hour >= 12 { "PM" } else { "AM" };
119 let twelve_hour = if hour % 12 == 0 { 12 } else { hour % 12 };
120 format!("{twelve_hour}:{minute:02} {period}")
121}
122
123fn to_ramadan_row(day: &PrayerData, roza: usize) -> RamadanRow {
124 RamadanRow {
125 roza,
126 sehar: to_12_hour_time(&day.timings.fajr),
127 iftar: to_12_hour_time(&day.timings.maghrib),
128 date: day.date.readable.clone(),
129 hijri: format!(
130 "{} {} {}",
131 day.date.hijri.day, day.date.hijri.month.en, day.date.hijri.year
132 ),
133 }
134}
135
136fn get_roza_number_from_hijri_day(day: &PrayerData) -> usize {
137 day.date.hijri.day.parse::<usize>().unwrap_or(1)
138}
139
140fn parse_iso_date(value: &str) -> Option<NaiveDate> {
141 NaiveDate::parse_from_str(value, "%Y-%m-%d").ok()
142}
143
144fn parse_gregorian_date(value: &str) -> Option<NaiveDate> {
145 NaiveDate::parse_from_str(value, "%d-%m-%Y").ok()
146}
147
148fn add_days(date: NaiveDate, days: i64) -> NaiveDate {
149 date + chrono::TimeDelta::days(days)
150}
151
152fn to_utc_date_only_ms(date: NaiveDate) -> i64 {
153 let datetime = date.and_hms_opt(0, 0, 0).expect("valid date");
154 datetime.and_utc().timestamp_millis()
155}
156
157pub fn get_roza_number_from_start_date(first_roza_date: NaiveDate, target_date: NaiveDate) -> i64 {
158 ((to_utc_date_only_ms(target_date) - to_utc_date_only_ms(first_roza_date)) / DAY_MS) + 1
159}
160
161fn parse_prayer_time_to_minutes(value: &str) -> Option<i64> {
162 let clean_value = value.split_whitespace().next().unwrap_or(value);
163 let mut parts = clean_value.split(':');
164 let hour = parts.next().and_then(|p| p.parse::<i64>().ok())?;
165 let minute = parts.next().and_then(|p| p.parse::<i64>().ok())?;
166
167 if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) {
168 return None;
169 }
170
171 Some((hour * 60) + minute)
172}
173
174#[derive(Debug, Clone, Copy)]
175struct GregorianDay {
176 year: i32,
177 month: u32,
178 day: u32,
179}
180
181fn parse_gregorian_day(value: &str) -> Option<GregorianDay> {
182 let date = parse_gregorian_date(value)?;
183 Some(GregorianDay {
184 year: date.year(),
185 month: date.month(),
186 day: date.day(),
187 })
188}
189
190#[derive(Debug, Clone, Copy)]
191struct TimezoneNowParts {
192 year: i32,
193 month: u32,
194 day: u32,
195 minutes: i64,
196}
197
198fn now_in_timezone_parts(timezone: &str) -> Option<TimezoneNowParts> {
199 let parsed: Tz = timezone.parse().ok()?;
200 let now = chrono::Utc::now().with_timezone(&parsed);
201
202 Some(TimezoneNowParts {
203 year: now.year(),
204 month: now.month(),
205 day: now.day(),
206 minutes: i64::from(now.hour() as i32 * 60 + now.minute() as i32),
207 })
208}
209
210fn format_countdown(minutes: i64) -> String {
211 let safe_minutes = minutes.max(0);
212 let hours = safe_minutes / 60;
213 let remaining_minutes = safe_minutes % 60;
214
215 if hours == 0 {
216 return format!("{remaining_minutes}m");
217 }
218
219 format!("{hours}h {remaining_minutes}m")
220}
221
222fn get_highlight_state(day: &PrayerData) -> Option<HighlightState> {
223 let day_parts = parse_gregorian_day(&day.date.gregorian.date)?;
224 let sehar_minutes = parse_prayer_time_to_minutes(&day.timings.fajr)?;
225 let iftar_minutes = parse_prayer_time_to_minutes(&day.timings.maghrib)?;
226 let now_parts = now_in_timezone_parts(&day.meta.timezone)?;
227
228 let now_date = NaiveDate::from_ymd_opt(now_parts.year, now_parts.month, now_parts.day)?;
229 let target_date = NaiveDate::from_ymd_opt(day_parts.year, day_parts.month, day_parts.day)?;
230 let day_diff = target_date.signed_duration_since(now_date).num_days();
231
232 if day_diff > 0 {
233 let minutes_until_sehar = (day_diff * MINUTES_IN_DAY) + (sehar_minutes - now_parts.minutes);
234 return Some(HighlightState {
235 current: "Before roza day".to_string(),
236 next: "First Sehar".to_string(),
237 countdown: format_countdown(minutes_until_sehar),
238 });
239 }
240
241 if day_diff < 0 {
242 return None;
243 }
244
245 if now_parts.minutes < sehar_minutes {
246 return Some(HighlightState {
247 current: "Sehar window open".to_string(),
248 next: "Roza starts (Fajr)".to_string(),
249 countdown: format_countdown(sehar_minutes - now_parts.minutes),
250 });
251 }
252
253 if now_parts.minutes < iftar_minutes {
254 return Some(HighlightState {
255 current: "Roza in progress".to_string(),
256 next: "Iftar".to_string(),
257 countdown: format_countdown(iftar_minutes - now_parts.minutes),
258 });
259 }
260
261 let minutes_until_next_sehar = MINUTES_IN_DAY - now_parts.minutes + sehar_minutes;
262 Some(HighlightState {
263 current: "Iftar time".to_string(),
264 next: "Next day Sehar".to_string(),
265 countdown: format_countdown(minutes_until_next_sehar),
266 })
267}
268
269fn get_configured_first_roza_date(opts: &RamadanCommandOptions) -> Result<Option<NaiveDate>> {
270 if opts.clear_first_roza_date {
271 clear_stored_first_roza_date()?;
272 return Ok(None);
273 }
274
275 if let Some(explicit) = &opts.first_roza_date {
276 let Some(parsed) = parse_iso_date(explicit) else {
277 return Err(anyhow!("Invalid first roza date. Use YYYY-MM-DD."));
278 };
279 set_stored_first_roza_date(explicit)?;
280 return Ok(Some(parsed));
281 }
282
283 let Some(stored) = get_stored_first_roza_date() else {
284 return Ok(None);
285 };
286
287 if let Some(parsed) = parse_iso_date(&stored) {
288 return Ok(Some(parsed));
289 }
290
291 clear_stored_first_roza_date()?;
292 Ok(None)
293}
294
295pub fn get_target_ramadan_year(today: &PrayerData) -> i64 {
296 let hijri_year = today.date.hijri.year.parse::<i64>().unwrap_or(0);
297 let hijri_month = today.date.hijri.month.number;
298 if hijri_month > 9 {
299 hijri_year + 1
300 } else {
301 hijri_year
302 }
303}
304
305fn format_row_annotation(kind: RowAnnotationKind) -> &'static str {
306 match kind {
307 RowAnnotationKind::Current => "← current",
308 RowAnnotationKind::Next => "← next",
309 }
310}
311
312fn print_table(rows: &[RamadanRow], row_annotations: &HashMap<usize, RowAnnotationKind>) {
313 let headers = ["Roza", "Sehar", "Iftar", "Date", "Hijri"];
314 let widths = [6, 8, 8, 14, 20];
315
316 let line = |columns: &[String]| -> String {
317 columns
318 .iter()
319 .enumerate()
320 .map(|(index, column)| format!("{column:<width$}", width = widths[index]))
321 .collect::<Vec<_>>()
322 .join(" ")
323 };
324
325 let header_columns = headers.iter().map(|v| v.to_string()).collect::<Vec<_>>();
326 let header_line = line(&header_columns);
327
328 println!(" {header_line}");
329 println!(" {}", "-".repeat(header_line.len()));
330
331 for row in rows {
332 let row_line = line(&[
333 row.roza.to_string(),
334 row.sehar.clone(),
335 row.iftar.clone(),
336 row.date.clone(),
337 row.hijri.clone(),
338 ]);
339
340 if let Some(annotation) = row_annotations.get(&row.roza).copied() {
341 println!(" {row_line} {}", format_row_annotation(annotation));
342 } else {
343 println!(" {row_line}");
344 }
345 }
346}
347
348fn get_error_message(error: &anyhow::Error) -> String {
349 error.to_string()
350}
351
352pub fn get_json_error_code(message: &str) -> &'static str {
353 if message.starts_with("Invalid first roza date") {
354 return "INVALID_FIRST_ROZA_DATE";
355 }
356 if message.contains("Use either --all or --number") {
357 return "INVALID_FLAG_COMBINATION";
358 }
359 if message.starts_with("Could not fetch prayer times.") {
360 return "PRAYER_TIMES_FETCH_FAILED";
361 }
362 if message.starts_with("Could not fetch Ramadan calendar.") {
363 return "RAMADAN_CALENDAR_FETCH_FAILED";
364 }
365 if message.starts_with("Could not detect location.") {
366 return "LOCATION_DETECTION_FAILED";
367 }
368 if message.starts_with("Could not find roza") {
369 return "ROZA_NOT_FOUND";
370 }
371 if message == "unknown error" {
372 return "UNKNOWN_ERROR";
373 }
374
375 "RAMADAN_CLI_ERROR"
376}
377
378pub fn to_json_error_payload(error: &anyhow::Error) -> JsonErrorPayload {
379 let message = get_error_message(error);
380 JsonErrorPayload {
381 ok: false,
382 error: JsonError {
383 code: get_json_error_code(&message).to_string(),
384 message,
385 },
386 }
387}
388
389fn parse_city_country(value: &str) -> Option<(String, String)> {
390 let parts: Vec<&str> = value
391 .split(',')
392 .map(|part| part.trim())
393 .filter(|part| !part.is_empty())
394 .collect();
395
396 if parts.len() < 2 {
397 return None;
398 }
399
400 let city = normalize_city_alias(parts[0]);
401 if city.is_empty() {
402 return None;
403 }
404
405 let country = parts[1..].join(", ").trim().to_string();
406 if country.is_empty() {
407 return None;
408 }
409
410 Some((city, country))
411}
412
413fn get_address_from_guess(guessed: &GeoLocation) -> String {
414 format!("{}, {}", guessed.city, guessed.country)
415}
416
417fn with_stored_settings(query: RamadanQuery) -> RamadanQuery {
418 let settings = get_stored_prayer_settings();
419 RamadanQuery {
420 method: Some(settings.method),
421 school: Some(settings.school),
422 timezone: settings.timezone,
423 ..query
424 }
425}
426
427fn with_country_aware_settings(
428 query: RamadanQuery,
429 country: &str,
430 city_timezone: Option<&str>,
431) -> RamadanQuery {
432 let settings = get_stored_prayer_settings();
433
434 let mut method = settings.method;
435 if let Some(recommended_method) = get_recommended_method(country) {
436 if should_apply_recommended_method(settings.method, recommended_method) {
437 method = recommended_method;
438 }
439 }
440
441 let mut school = settings.school;
442 let recommended_school = get_recommended_school(country);
443 if should_apply_recommended_school(settings.school, recommended_school) {
444 school = recommended_school;
445 }
446
447 RamadanQuery {
448 method: Some(method),
449 school: Some(school),
450 timezone: city_timezone.map(|v| v.to_string()).or(settings.timezone),
451 ..query
452 }
453}
454
455fn get_stored_query() -> Option<RamadanQuery> {
456 if !has_stored_location() {
457 return None;
458 }
459
460 let location = get_stored_location();
461
462 if let (Some(city), Some(country)) = (location.city.clone(), location.country.clone()) {
463 let query = RamadanQuery {
464 address: format!("{city}, {country}"),
465 city: Some(city),
466 country: Some(country),
467 latitude: location.latitude,
468 longitude: location.longitude,
469 method: None,
470 school: None,
471 timezone: None,
472 };
473
474 return Some(with_stored_settings(query));
475 }
476
477 if let (Some(latitude), Some(longitude)) = (location.latitude, location.longitude) {
478 let query = RamadanQuery {
479 address: format!("{latitude}, {longitude}"),
480 city: None,
481 country: None,
482 latitude: Some(latitude),
483 longitude: Some(longitude),
484 method: None,
485 school: None,
486 timezone: None,
487 };
488
489 return Some(with_stored_settings(query));
490 }
491
492 None
493}
494
495fn resolve_query_from_city_input(client: &Client, city: &str) -> RamadanQuery {
496 let normalized = normalize_city_alias(city);
497
498 if let Some((parsed_city, parsed_country)) = parse_city_country(&normalized) {
499 return with_country_aware_settings(
500 RamadanQuery {
501 address: format!("{parsed_city}, {parsed_country}"),
502 city: Some(parsed_city),
503 country: Some(parsed_country.clone()),
504 latitude: None,
505 longitude: None,
506 method: None,
507 school: None,
508 timezone: None,
509 },
510 &parsed_country,
511 None,
512 );
513 }
514
515 if let Some(guessed) = guess_city_country(client, &normalized) {
516 return with_country_aware_settings(
517 RamadanQuery {
518 address: format!("{}, {}", guessed.city, guessed.country),
519 city: Some(guessed.city),
520 country: Some(guessed.country.clone()),
521 latitude: Some(guessed.latitude),
522 longitude: Some(guessed.longitude),
523 method: None,
524 school: None,
525 timezone: None,
526 },
527 &guessed.country,
528 guessed.timezone.as_deref(),
529 );
530 }
531
532 with_stored_settings(RamadanQuery {
533 address: normalized,
534 city: None,
535 country: None,
536 latitude: None,
537 longitude: None,
538 method: None,
539 school: None,
540 timezone: None,
541 })
542}
543
544fn resolve_query(
545 client: &Client,
546 city: Option<&str>,
547 allow_interactive_setup: bool,
548) -> Result<RamadanQuery> {
549 if let Some(city) = city {
550 return Ok(resolve_query_from_city_input(client, city));
551 }
552
553 if let Some(stored_query) = get_stored_query() {
554 return Ok(stored_query);
555 }
556
557 if allow_interactive_setup && can_prompt_interactively() {
558 let configured = match run_first_run_setup(client) {
559 Ok(configured) => configured,
560 Err(error) => {
561 let message = error.to_string().to_ascii_lowercase();
562 if message.contains("interrupted") || message.contains("cancel") {
563 return Err(anyhow!("SETUP_CANCELLED"));
564 }
565 return Err(error);
566 }
567 };
568
569 if configured {
570 if let Some(configured_query) = get_stored_query() {
571 return Ok(configured_query);
572 }
573 } else {
574 return Err(anyhow!("SETUP_CANCELLED"));
575 }
576 }
577
578 let guessed = guess_location(client).ok_or_else(|| {
579 anyhow!("Could not detect location. Pass a city like `ramadan-cli \"Lahore\"`.")
580 })?;
581
582 save_auto_detected_setup(&guessed)?;
583
584 Ok(with_stored_settings(RamadanQuery {
585 address: get_address_from_guess(&guessed),
586 city: Some(guessed.city),
587 country: Some(guessed.country),
588 latitude: Some(guessed.latitude),
589 longitude: Some(guessed.longitude),
590 method: None,
591 school: None,
592 timezone: None,
593 }))
594}
595
596fn fetch_ramadan_day(
597 client: &Client,
598 query: &RamadanQuery,
599 date: Option<NaiveDate>,
600) -> Result<PrayerData> {
601 let mut errors: Vec<String> = Vec::new();
602
603 let by_address = fetch_timings_by_address(
604 client,
605 &FetchByAddressOptions {
606 address: query.address.clone(),
607 method: query.method,
608 school: query.school,
609 date,
610 },
611 );
612
613 match by_address {
614 Ok(day) => return Ok(day),
615 Err(error) => errors.push(format!("timingsByAddress failed: {}", error)),
616 }
617
618 if let (Some(city), Some(country)) = (&query.city, &query.country) {
619 let by_city = fetch_timings_by_city(
620 client,
621 &FetchByCityOptions {
622 city: city.clone(),
623 country: country.clone(),
624 method: query.method,
625 school: query.school,
626 date,
627 },
628 );
629
630 match by_city {
631 Ok(day) => return Ok(day),
632 Err(error) => errors.push(format!("timingsByCity failed: {}", error)),
633 }
634 }
635
636 if let (Some(latitude), Some(longitude)) = (query.latitude, query.longitude) {
637 let by_coords = fetch_timings_by_coords(
638 client,
639 &FetchByCoordsOptions {
640 latitude,
641 longitude,
642 method: query.method,
643 school: query.school,
644 timezone: query.timezone.clone(),
645 date,
646 },
647 );
648
649 match by_coords {
650 Ok(day) => return Ok(day),
651 Err(error) => errors.push(format!("timingsByCoords failed: {}", error)),
652 }
653 }
654
655 Err(anyhow!(
656 "Could not fetch prayer times. {}",
657 errors.join(" | ")
658 ))
659}
660
661fn fetch_ramadan_calendar(
662 client: &Client,
663 query: &RamadanQuery,
664 year: i64,
665) -> Result<Vec<PrayerData>> {
666 let mut errors: Vec<String> = Vec::new();
667
668 let by_address = fetch_hijri_calendar_by_address(
669 client,
670 &FetchHijriCalendarByAddressOptions {
671 address: query.address.clone(),
672 year,
673 month: 9,
674 method: query.method,
675 school: query.school,
676 },
677 );
678
679 match by_address {
680 Ok(days) => return Ok(days),
681 Err(error) => errors.push(format!("hijriCalendarByAddress failed: {}", error)),
682 }
683
684 if let (Some(city), Some(country)) = (&query.city, &query.country) {
685 let by_city = fetch_hijri_calendar_by_city(
686 client,
687 &FetchHijriCalendarByCityOptions {
688 city: city.clone(),
689 country: country.clone(),
690 year,
691 month: 9,
692 method: query.method,
693 school: query.school,
694 },
695 );
696
697 match by_city {
698 Ok(days) => return Ok(days),
699 Err(error) => errors.push(format!("hijriCalendarByCity failed: {}", error)),
700 }
701 }
702
703 Err(anyhow!(
704 "Could not fetch Ramadan calendar. {}",
705 errors.join(" | ")
706 ))
707}
708
709fn fetch_custom_ramadan_days(
710 client: &Client,
711 query: &RamadanQuery,
712 first_roza_date: NaiveDate,
713) -> Result<Vec<PrayerData>> {
714 let mut days = Vec::with_capacity(30);
715 for index in 0..30 {
716 let day_date = add_days(first_roza_date, index as i64);
717 days.push(fetch_ramadan_day(client, query, Some(day_date))?);
718 }
719
720 Ok(days)
721}
722
723fn get_row_by_roza_number(days: &[PrayerData], roza_number: usize) -> Result<RamadanRow> {
724 let Some(day) = days.get(roza_number.saturating_sub(1)) else {
725 return Err(anyhow!("Could not find roza {roza_number} timings."));
726 };
727
728 Ok(to_ramadan_row(day, roza_number))
729}
730
731fn get_day_by_roza_number(days: &[PrayerData], roza_number: usize) -> Result<PrayerData> {
732 let Some(day) = days.get(roza_number.saturating_sub(1)) else {
733 return Err(anyhow!("Could not find roza {roza_number} timings."));
734 };
735
736 Ok(day.clone())
737}
738
739fn get_hijri_year_from_roza_number(
740 days: &[PrayerData],
741 roza_number: usize,
742 fallback_year: i64,
743) -> i64 {
744 days.get(roza_number.saturating_sub(1))
745 .and_then(|day| day.date.hijri.year.parse::<i64>().ok())
746 .unwrap_or(fallback_year)
747}
748
749fn set_row_annotation(
750 annotations: &mut HashMap<usize, RowAnnotationKind>,
751 roza: i64,
752 kind: RowAnnotationKind,
753) {
754 if (1..=30).contains(&roza) {
755 annotations.insert(roza as usize, kind);
756 }
757}
758
759fn get_all_mode_row_annotations(
760 today: &PrayerData,
761 today_gregorian_date: NaiveDate,
762 target_year: i64,
763 configured_first_roza_date: Option<NaiveDate>,
764) -> HashMap<usize, RowAnnotationKind> {
765 let mut annotations: HashMap<usize, RowAnnotationKind> = HashMap::new();
766
767 if let Some(first_roza_date) = configured_first_roza_date {
768 let current_roza = get_roza_number_from_start_date(first_roza_date, today_gregorian_date);
769
770 if current_roza < 1 {
771 set_row_annotation(&mut annotations, 1, RowAnnotationKind::Next);
772 return annotations;
773 }
774
775 set_row_annotation(&mut annotations, current_roza, RowAnnotationKind::Current);
776 set_row_annotation(&mut annotations, current_roza + 1, RowAnnotationKind::Next);
777 return annotations;
778 }
779
780 let today_hijri_year = today.date.hijri.year.parse::<i64>().unwrap_or(0);
781 let is_ramadan_now = today.date.hijri.month.number == 9 && today_hijri_year == target_year;
782
783 if !is_ramadan_now {
784 set_row_annotation(&mut annotations, 1, RowAnnotationKind::Next);
785 return annotations;
786 }
787
788 let current_roza = get_roza_number_from_hijri_day(today) as i64;
789 set_row_annotation(&mut annotations, current_roza, RowAnnotationKind::Current);
790 set_row_annotation(&mut annotations, current_roza + 1, RowAnnotationKind::Next);
791 annotations
792}
793
794fn print_text_output(
795 output: &RamadanOutput,
796 plain: bool,
797 highlight: Option<&HighlightState>,
798 row_annotations: &HashMap<usize, RowAnnotationKind>,
799) {
800 let title = match output.mode.as_str() {
801 "all" => format!("Ramadan {} (All Days)", output.hijri_year),
802 "number" => format!(
803 "Roza {} Sehar/Iftar",
804 output.rows.first().map(|r| r.roza).unwrap_or_default()
805 ),
806 _ => "Today Sehar/Iftar".to_string(),
807 };
808
809 if plain {
810 println!("RAMADAN CLI");
811 } else {
812 println!("{}", get_banner());
813 }
814
815 println!("{}", ramadan_green(&format!(" {title}")));
816 println!(" 📍 {}", output.location);
817 println!();
818
819 print_table(&output.rows, row_annotations);
820 println!();
821
822 if let Some(highlight) = highlight {
823 println!(" {} {}", ramadan_green("Status:"), highlight.current);
824 println!(
825 " {} {} in {}",
826 ramadan_green("Up next:"),
827 highlight.next,
828 highlight.countdown
829 );
830 println!();
831 }
832
833 println!(" Sehar uses Fajr. Iftar uses Maghrib.");
834 println!();
835}
836
837pub fn ramadan_command(
838 client: &Client,
839 opts: &RamadanCommandOptions,
840) -> Result<Option<RamadanOutput>> {
841 let configured_first_roza_date = get_configured_first_roza_date(opts)?;
842 let query = resolve_query(client, opts.city.as_deref(), !opts.json)?;
843 let today = fetch_ramadan_day(client, &query, None)?;
844 let today_gregorian_date = parse_gregorian_date(&today.date.gregorian.date)
845 .ok_or_else(|| anyhow!("Could not parse Gregorian date from prayer response."))?;
846
847 let target_year = get_target_ramadan_year(&today);
848 let has_custom_first_roza_date = configured_first_roza_date.is_some();
849
850 if opts.all && opts.roza_number.is_some() {
851 return Err(anyhow!("Use either --all or --number, not both."));
852 }
853
854 if let Some(roza_number) = opts.roza_number {
855 let (row, selected_day, hijri_year) = if has_custom_first_roza_date {
856 let first_roza_date = configured_first_roza_date
857 .ok_or_else(|| anyhow!("Could not determine first roza date."))?;
858 let custom_days = fetch_custom_ramadan_days(client, &query, first_roza_date)?;
859 (
860 get_row_by_roza_number(&custom_days, roza_number)?,
861 get_day_by_roza_number(&custom_days, roza_number)?,
862 get_hijri_year_from_roza_number(&custom_days, roza_number, target_year),
863 )
864 } else {
865 let calendar = fetch_ramadan_calendar(client, &query, target_year)?;
866 (
867 get_row_by_roza_number(&calendar, roza_number)?,
868 get_day_by_roza_number(&calendar, roza_number)?,
869 get_hijri_year_from_roza_number(&calendar, roza_number, target_year),
870 )
871 };
872
873 let output = RamadanOutput {
874 mode: "number".to_string(),
875 location: query.address,
876 hijri_year,
877 rows: vec![row],
878 };
879
880 if opts.json {
881 return Ok(Some(output));
882 }
883
884 let annotations = HashMap::new();
885 let highlight = get_highlight_state(&selected_day);
886 print_text_output(&output, opts.plain, highlight.as_ref(), &annotations);
887 return Ok(None);
888 }
889
890 if !opts.all {
891 let (row, highlight_day, output_hijri_year) = if has_custom_first_roza_date {
892 let first_roza_date = configured_first_roza_date
893 .ok_or_else(|| anyhow!("Could not determine first roza date."))?;
894 let roza_number =
895 get_roza_number_from_start_date(first_roza_date, today_gregorian_date);
896
897 if roza_number < 1 {
898 let first_roza_day = fetch_ramadan_day(client, &query, Some(first_roza_date))?;
899 let hijri_year = first_roza_day
900 .date
901 .hijri
902 .year
903 .parse::<i64>()
904 .unwrap_or(target_year);
905 (
906 to_ramadan_row(&first_roza_day, 1),
907 first_roza_day,
908 hijri_year,
909 )
910 } else {
911 let hijri_year = today.date.hijri.year.parse::<i64>().unwrap_or(target_year);
912 (
913 to_ramadan_row(&today, roza_number as usize),
914 today.clone(),
915 hijri_year,
916 )
917 }
918 } else if today.date.hijri.month.number == 9 {
919 (
920 to_ramadan_row(&today, get_roza_number_from_hijri_day(&today)),
921 today.clone(),
922 target_year,
923 )
924 } else {
925 let calendar = fetch_ramadan_calendar(client, &query, target_year)?;
926 let first_day = calendar
927 .first()
928 .ok_or_else(|| anyhow!("Could not find the first day of Ramadan."))?
929 .clone();
930 let hijri_year = first_day
931 .date
932 .hijri
933 .year
934 .parse::<i64>()
935 .unwrap_or(target_year);
936 (to_ramadan_row(&first_day, 1), first_day, hijri_year)
937 };
938
939 let output = RamadanOutput {
940 mode: "today".to_string(),
941 location: query.address,
942 hijri_year: output_hijri_year,
943 rows: vec![row],
944 };
945
946 if opts.json {
947 return Ok(Some(output));
948 }
949
950 let highlight = get_highlight_state(&highlight_day);
951 let annotations = HashMap::new();
952 print_text_output(&output, opts.plain, highlight.as_ref(), &annotations);
953 return Ok(None);
954 }
955
956 let (rows, hijri_year) = if has_custom_first_roza_date {
957 let first_roza_date = configured_first_roza_date
958 .ok_or_else(|| anyhow!("Could not determine first roza date."))?;
959 let custom_days = fetch_custom_ramadan_days(client, &query, first_roza_date)?;
960 let rows = custom_days
961 .iter()
962 .enumerate()
963 .map(|(index, day)| to_ramadan_row(day, index + 1))
964 .collect::<Vec<_>>();
965 let hijri_year = custom_days
966 .first()
967 .and_then(|day| day.date.hijri.year.parse::<i64>().ok())
968 .unwrap_or(target_year);
969 (rows, hijri_year)
970 } else {
971 let calendar = fetch_ramadan_calendar(client, &query, target_year)?;
972 let rows = calendar
973 .iter()
974 .enumerate()
975 .map(|(index, day)| to_ramadan_row(day, index + 1))
976 .collect::<Vec<_>>();
977 (rows, target_year)
978 };
979
980 let output = RamadanOutput {
981 mode: "all".to_string(),
982 location: query.address,
983 hijri_year,
984 rows,
985 };
986
987 if opts.json {
988 return Ok(Some(output));
989 }
990
991 let row_annotations = get_all_mode_row_annotations(
992 &today,
993 today_gregorian_date,
994 target_year,
995 configured_first_roza_date,
996 );
997 let highlight = get_highlight_state(&today);
998 print_text_output(&output, opts.plain, highlight.as_ref(), &row_annotations);
999 Ok(None)
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use chrono::NaiveDate;
1005 use serde_json::json;
1006
1007 use crate::api::PrayerData;
1008
1009 use super::{
1010 RamadanOutput, get_json_error_code, get_roza_number_from_start_date,
1011 get_target_ramadan_year, normalize_city_alias, to_12_hour_time,
1012 };
1013
1014 fn sample_prayer_data(hijri_month: i64, hijri_year: &str) -> PrayerData {
1015 serde_json::from_value(json!({
1016 "timings": {
1017 "Fajr": "05:30",
1018 "Sunrise": "06:45",
1019 "Dhuhr": "12:30",
1020 "Asr": "15:45",
1021 "Sunset": "18:15",
1022 "Maghrib": "18:15",
1023 "Isha": "19:45",
1024 "Imsak": "05:20",
1025 "Midnight": "00:00",
1026 "Firstthird": "22:00",
1027 "Lastthird": "02:00"
1028 },
1029 "date": {
1030 "readable": "01 Feb 2026",
1031 "timestamp": "1738368000",
1032 "hijri": {
1033 "date": "03-09-1447",
1034 "day": "3",
1035 "month": {
1036 "number": hijri_month,
1037 "en": "Ramadan",
1038 "ar": "رَمَضَان"
1039 },
1040 "year": hijri_year,
1041 "weekday": {
1042 "en": "Sunday",
1043 "ar": "الأحد"
1044 }
1045 },
1046 "gregorian": {
1047 "date": "01-02-2026",
1048 "day": "01",
1049 "month": {
1050 "number": 2,
1051 "en": "February"
1052 },
1053 "year": "2026",
1054 "weekday": {
1055 "en": "Sunday"
1056 }
1057 }
1058 },
1059 "meta": {
1060 "latitude": 31.5204,
1061 "longitude": 74.3587,
1062 "timezone": "Asia/Karachi",
1063 "method": {
1064 "id": 1,
1065 "name": "Karachi"
1066 },
1067 "school": {
1068 "id": 1,
1069 "name": "Hanafi"
1070 }
1071 }
1072 }))
1073 .expect("sample prayer data should deserialize")
1074 }
1075
1076 #[test]
1077 fn get_roza_number_from_start_date_handles_boundaries() {
1078 let first_roza_date = NaiveDate::from_ymd_opt(2026, 2, 18).expect("valid date");
1079
1080 assert_eq!(
1081 get_roza_number_from_start_date(
1082 first_roza_date,
1083 NaiveDate::from_ymd_opt(2026, 2, 18).expect("valid")
1084 ),
1085 1
1086 );
1087 assert_eq!(
1088 get_roza_number_from_start_date(
1089 first_roza_date,
1090 NaiveDate::from_ymd_opt(2026, 2, 20).expect("valid")
1091 ),
1092 3
1093 );
1094 assert_eq!(
1095 get_roza_number_from_start_date(
1096 first_roza_date,
1097 NaiveDate::from_ymd_opt(2026, 2, 17).expect("valid")
1098 ),
1099 0
1100 );
1101 }
1102
1103 #[test]
1104 fn time_formatting_matches_expected_values() {
1105 assert_eq!(to_12_hour_time("05:48"), "5:48 AM");
1106 assert_eq!(to_12_hour_time("17:38"), "5:38 PM");
1107 assert_eq!(to_12_hour_time("17:38 (PST)"), "5:38 PM");
1108 assert_eq!(to_12_hour_time("not-a-time"), "not-a-time");
1109 }
1110
1111 #[test]
1112 fn alias_normalization_matches_cli_contract() {
1113 assert_eq!(normalize_city_alias("sf"), "San Francisco");
1114 assert_eq!(normalize_city_alias("SF"), "San Francisco");
1115 assert_eq!(normalize_city_alias("lahore"), "lahore");
1116 }
1117
1118 #[test]
1119 fn json_error_codes_are_stable() {
1120 assert_eq!(
1121 get_json_error_code("Could not fetch prayer times. timingsByAddress failed"),
1122 "PRAYER_TIMES_FETCH_FAILED"
1123 );
1124 assert_eq!(
1125 get_json_error_code("Use either --all or --number, not both."),
1126 "INVALID_FLAG_COMBINATION"
1127 );
1128 }
1129
1130 #[test]
1131 fn target_ramadan_year_matches_ts_logic() {
1132 assert_eq!(
1133 get_target_ramadan_year(&sample_prayer_data(8, "1447")),
1134 1447
1135 );
1136 assert_eq!(
1137 get_target_ramadan_year(&sample_prayer_data(10, "1447")),
1138 1448
1139 );
1140 }
1141
1142 #[test]
1143 fn json_output_uses_hijri_year_camel_case() {
1144 let output = RamadanOutput {
1145 mode: "today".to_string(),
1146 location: "Lahore, Pakistan".to_string(),
1147 hijri_year: 1447,
1148 rows: vec![],
1149 };
1150
1151 let value = serde_json::to_value(output).expect("output should serialize");
1152 assert!(value.get("hijriYear").is_some());
1153 assert!(value.get("hijri_year").is_none());
1154 }
1155}