Skip to main content

ramadan_cli/commands/
ramadan.rs

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}