use std::collections::HashMap;
use anyhow::{Result, anyhow};
use chrono::{Datelike, NaiveDate, Timelike};
use chrono_tz::Tz;
use reqwest::blocking::Client;
use serde::Serialize;
use crate::api::{
FetchByAddressOptions, FetchByCityOptions, FetchByCoordsOptions,
FetchHijriCalendarByAddressOptions, FetchHijriCalendarByCityOptions, PrayerData,
fetch_hijri_calendar_by_address, fetch_hijri_calendar_by_city, fetch_timings_by_address,
fetch_timings_by_city, fetch_timings_by_coords,
};
use crate::geo::{GeoLocation, guess_city_country, guess_location};
use crate::ramadan_config::{
clear_stored_first_roza_date, get_stored_first_roza_date, get_stored_location,
get_stored_prayer_settings, has_stored_location, save_auto_detected_setup,
set_stored_first_roza_date, should_apply_recommended_method, should_apply_recommended_school,
};
use crate::recommendations::{get_recommended_method, get_recommended_school};
use crate::setup::{can_prompt_interactively, run_first_run_setup};
use crate::ui::banner::get_banner;
use crate::ui::theme::ramadan_green;
#[derive(Debug, Clone, Default)]
pub struct RamadanCommandOptions {
pub city: Option<String>,
pub all: bool,
pub roza_number: Option<usize>,
pub plain: bool,
pub json: bool,
pub first_roza_date: Option<String>,
pub clear_first_roza_date: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RamadanRow {
pub roza: usize,
pub sehar: String,
pub iftar: String,
pub date: String,
pub hijri: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RamadanOutput {
pub mode: String,
pub location: String,
#[serde(rename = "hijriYear")]
pub hijri_year: i64,
pub rows: Vec<RamadanRow>,
}
#[derive(Debug, Serialize)]
pub struct JsonErrorPayload {
ok: bool,
error: JsonError,
}
#[derive(Debug, Serialize)]
struct JsonError {
code: String,
message: String,
}
#[derive(Debug, Clone)]
struct HighlightState {
current: String,
next: String,
countdown: String,
}
#[derive(Debug, Copy, Clone)]
enum RowAnnotationKind {
Current,
Next,
}
#[derive(Debug, Clone)]
struct RamadanQuery {
address: String,
city: Option<String>,
country: Option<String>,
latitude: Option<f64>,
longitude: Option<f64>,
method: Option<i64>,
school: Option<i64>,
timezone: Option<String>,
}
const DAY_MS: i64 = 24 * 60 * 60 * 1000;
const MINUTES_IN_DAY: i64 = 24 * 60;
pub fn normalize_city_alias(city: &str) -> String {
let trimmed = city.trim();
if trimmed.eq_ignore_ascii_case("sf") {
return "San Francisco".to_string();
}
trimmed.to_string()
}
pub fn to_12_hour_time(value: &str) -> String {
let clean_value = value.split_whitespace().next().unwrap_or(value);
let mut parts = clean_value.split(':');
let hour = parts.next().and_then(|p| p.parse::<i64>().ok());
let minute = parts.next().and_then(|p| p.parse::<i64>().ok());
let (Some(hour), Some(minute)) = (hour, minute) else {
return clean_value.to_string();
};
if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) {
return clean_value.to_string();
}
let period = if hour >= 12 { "PM" } else { "AM" };
let twelve_hour = if hour % 12 == 0 { 12 } else { hour % 12 };
format!("{twelve_hour}:{minute:02} {period}")
}
fn to_ramadan_row(day: &PrayerData, roza: usize) -> RamadanRow {
RamadanRow {
roza,
sehar: to_12_hour_time(&day.timings.fajr),
iftar: to_12_hour_time(&day.timings.maghrib),
date: day.date.readable.clone(),
hijri: format!(
"{} {} {}",
day.date.hijri.day, day.date.hijri.month.en, day.date.hijri.year
),
}
}
fn get_roza_number_from_hijri_day(day: &PrayerData) -> usize {
day.date.hijri.day.parse::<usize>().unwrap_or(1)
}
fn parse_iso_date(value: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value, "%Y-%m-%d").ok()
}
fn parse_gregorian_date(value: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value, "%d-%m-%Y").ok()
}
fn add_days(date: NaiveDate, days: i64) -> NaiveDate {
date + chrono::TimeDelta::days(days)
}
fn to_utc_date_only_ms(date: NaiveDate) -> i64 {
let datetime = date.and_hms_opt(0, 0, 0).expect("valid date");
datetime.and_utc().timestamp_millis()
}
pub fn get_roza_number_from_start_date(first_roza_date: NaiveDate, target_date: NaiveDate) -> i64 {
((to_utc_date_only_ms(target_date) - to_utc_date_only_ms(first_roza_date)) / DAY_MS) + 1
}
fn parse_prayer_time_to_minutes(value: &str) -> Option<i64> {
let clean_value = value.split_whitespace().next().unwrap_or(value);
let mut parts = clean_value.split(':');
let hour = parts.next().and_then(|p| p.parse::<i64>().ok())?;
let minute = parts.next().and_then(|p| p.parse::<i64>().ok())?;
if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) {
return None;
}
Some((hour * 60) + minute)
}
#[derive(Debug, Clone, Copy)]
struct GregorianDay {
year: i32,
month: u32,
day: u32,
}
fn parse_gregorian_day(value: &str) -> Option<GregorianDay> {
let date = parse_gregorian_date(value)?;
Some(GregorianDay {
year: date.year(),
month: date.month(),
day: date.day(),
})
}
#[derive(Debug, Clone, Copy)]
struct TimezoneNowParts {
year: i32,
month: u32,
day: u32,
minutes: i64,
}
fn now_in_timezone_parts(timezone: &str) -> Option<TimezoneNowParts> {
let parsed: Tz = timezone.parse().ok()?;
let now = chrono::Utc::now().with_timezone(&parsed);
Some(TimezoneNowParts {
year: now.year(),
month: now.month(),
day: now.day(),
minutes: i64::from(now.hour() as i32 * 60 + now.minute() as i32),
})
}
fn format_countdown(minutes: i64) -> String {
let safe_minutes = minutes.max(0);
let hours = safe_minutes / 60;
let remaining_minutes = safe_minutes % 60;
if hours == 0 {
return format!("{remaining_minutes}m");
}
format!("{hours}h {remaining_minutes}m")
}
fn get_highlight_state(day: &PrayerData) -> Option<HighlightState> {
let day_parts = parse_gregorian_day(&day.date.gregorian.date)?;
let sehar_minutes = parse_prayer_time_to_minutes(&day.timings.fajr)?;
let iftar_minutes = parse_prayer_time_to_minutes(&day.timings.maghrib)?;
let now_parts = now_in_timezone_parts(&day.meta.timezone)?;
let now_date = NaiveDate::from_ymd_opt(now_parts.year, now_parts.month, now_parts.day)?;
let target_date = NaiveDate::from_ymd_opt(day_parts.year, day_parts.month, day_parts.day)?;
let day_diff = target_date.signed_duration_since(now_date).num_days();
if day_diff > 0 {
let minutes_until_sehar = (day_diff * MINUTES_IN_DAY) + (sehar_minutes - now_parts.minutes);
return Some(HighlightState {
current: "Before roza day".to_string(),
next: "First Sehar".to_string(),
countdown: format_countdown(minutes_until_sehar),
});
}
if day_diff < 0 {
return None;
}
if now_parts.minutes < sehar_minutes {
return Some(HighlightState {
current: "Sehar window open".to_string(),
next: "Roza starts (Fajr)".to_string(),
countdown: format_countdown(sehar_minutes - now_parts.minutes),
});
}
if now_parts.minutes < iftar_minutes {
return Some(HighlightState {
current: "Roza in progress".to_string(),
next: "Iftar".to_string(),
countdown: format_countdown(iftar_minutes - now_parts.minutes),
});
}
let minutes_until_next_sehar = MINUTES_IN_DAY - now_parts.minutes + sehar_minutes;
Some(HighlightState {
current: "Iftar time".to_string(),
next: "Next day Sehar".to_string(),
countdown: format_countdown(minutes_until_next_sehar),
})
}
fn get_configured_first_roza_date(opts: &RamadanCommandOptions) -> Result<Option<NaiveDate>> {
if opts.clear_first_roza_date {
clear_stored_first_roza_date()?;
return Ok(None);
}
if let Some(explicit) = &opts.first_roza_date {
let Some(parsed) = parse_iso_date(explicit) else {
return Err(anyhow!("Invalid first roza date. Use YYYY-MM-DD."));
};
set_stored_first_roza_date(explicit)?;
return Ok(Some(parsed));
}
let Some(stored) = get_stored_first_roza_date() else {
return Ok(None);
};
if let Some(parsed) = parse_iso_date(&stored) {
return Ok(Some(parsed));
}
clear_stored_first_roza_date()?;
Ok(None)
}
pub fn get_target_ramadan_year(today: &PrayerData) -> i64 {
let hijri_year = today.date.hijri.year.parse::<i64>().unwrap_or(0);
let hijri_month = today.date.hijri.month.number;
if hijri_month > 9 {
hijri_year + 1
} else {
hijri_year
}
}
fn format_row_annotation(kind: RowAnnotationKind) -> &'static str {
match kind {
RowAnnotationKind::Current => "← current",
RowAnnotationKind::Next => "← next",
}
}
fn print_table(rows: &[RamadanRow], row_annotations: &HashMap<usize, RowAnnotationKind>) {
let headers = ["Roza", "Sehar", "Iftar", "Date", "Hijri"];
let widths = [6, 8, 8, 14, 20];
let line = |columns: &[String]| -> String {
columns
.iter()
.enumerate()
.map(|(index, column)| format!("{column:<width$}", width = widths[index]))
.collect::<Vec<_>>()
.join(" ")
};
let header_columns = headers.iter().map(|v| v.to_string()).collect::<Vec<_>>();
let header_line = line(&header_columns);
println!(" {header_line}");
println!(" {}", "-".repeat(header_line.len()));
for row in rows {
let row_line = line(&[
row.roza.to_string(),
row.sehar.clone(),
row.iftar.clone(),
row.date.clone(),
row.hijri.clone(),
]);
if let Some(annotation) = row_annotations.get(&row.roza).copied() {
println!(" {row_line} {}", format_row_annotation(annotation));
} else {
println!(" {row_line}");
}
}
}
fn get_error_message(error: &anyhow::Error) -> String {
error.to_string()
}
pub fn get_json_error_code(message: &str) -> &'static str {
if message.starts_with("Invalid first roza date") {
return "INVALID_FIRST_ROZA_DATE";
}
if message.contains("Use either --all or --number") {
return "INVALID_FLAG_COMBINATION";
}
if message.starts_with("Could not fetch prayer times.") {
return "PRAYER_TIMES_FETCH_FAILED";
}
if message.starts_with("Could not fetch Ramadan calendar.") {
return "RAMADAN_CALENDAR_FETCH_FAILED";
}
if message.starts_with("Could not detect location.") {
return "LOCATION_DETECTION_FAILED";
}
if message.starts_with("Could not find roza") {
return "ROZA_NOT_FOUND";
}
if message == "unknown error" {
return "UNKNOWN_ERROR";
}
"RAMADAN_CLI_ERROR"
}
pub fn to_json_error_payload(error: &anyhow::Error) -> JsonErrorPayload {
let message = get_error_message(error);
JsonErrorPayload {
ok: false,
error: JsonError {
code: get_json_error_code(&message).to_string(),
message,
},
}
}
fn parse_city_country(value: &str) -> Option<(String, String)> {
let parts: Vec<&str> = value
.split(',')
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.collect();
if parts.len() < 2 {
return None;
}
let city = normalize_city_alias(parts[0]);
if city.is_empty() {
return None;
}
let country = parts[1..].join(", ").trim().to_string();
if country.is_empty() {
return None;
}
Some((city, country))
}
fn get_address_from_guess(guessed: &GeoLocation) -> String {
format!("{}, {}", guessed.city, guessed.country)
}
fn with_stored_settings(query: RamadanQuery) -> RamadanQuery {
let settings = get_stored_prayer_settings();
RamadanQuery {
method: Some(settings.method),
school: Some(settings.school),
timezone: settings.timezone,
..query
}
}
fn with_country_aware_settings(
query: RamadanQuery,
country: &str,
city_timezone: Option<&str>,
) -> RamadanQuery {
let settings = get_stored_prayer_settings();
let mut method = settings.method;
if let Some(recommended_method) = get_recommended_method(country) {
if should_apply_recommended_method(settings.method, recommended_method) {
method = recommended_method;
}
}
let mut school = settings.school;
let recommended_school = get_recommended_school(country);
if should_apply_recommended_school(settings.school, recommended_school) {
school = recommended_school;
}
RamadanQuery {
method: Some(method),
school: Some(school),
timezone: city_timezone.map(|v| v.to_string()).or(settings.timezone),
..query
}
}
fn get_stored_query() -> Option<RamadanQuery> {
if !has_stored_location() {
return None;
}
let location = get_stored_location();
if let (Some(city), Some(country)) = (location.city.clone(), location.country.clone()) {
let query = RamadanQuery {
address: format!("{city}, {country}"),
city: Some(city),
country: Some(country),
latitude: location.latitude,
longitude: location.longitude,
method: None,
school: None,
timezone: None,
};
return Some(with_stored_settings(query));
}
if let (Some(latitude), Some(longitude)) = (location.latitude, location.longitude) {
let query = RamadanQuery {
address: format!("{latitude}, {longitude}"),
city: None,
country: None,
latitude: Some(latitude),
longitude: Some(longitude),
method: None,
school: None,
timezone: None,
};
return Some(with_stored_settings(query));
}
None
}
fn resolve_query_from_city_input(client: &Client, city: &str) -> RamadanQuery {
let normalized = normalize_city_alias(city);
if let Some((parsed_city, parsed_country)) = parse_city_country(&normalized) {
return with_country_aware_settings(
RamadanQuery {
address: format!("{parsed_city}, {parsed_country}"),
city: Some(parsed_city),
country: Some(parsed_country.clone()),
latitude: None,
longitude: None,
method: None,
school: None,
timezone: None,
},
&parsed_country,
None,
);
}
if let Some(guessed) = guess_city_country(client, &normalized) {
return with_country_aware_settings(
RamadanQuery {
address: format!("{}, {}", guessed.city, guessed.country),
city: Some(guessed.city),
country: Some(guessed.country.clone()),
latitude: Some(guessed.latitude),
longitude: Some(guessed.longitude),
method: None,
school: None,
timezone: None,
},
&guessed.country,
guessed.timezone.as_deref(),
);
}
with_stored_settings(RamadanQuery {
address: normalized,
city: None,
country: None,
latitude: None,
longitude: None,
method: None,
school: None,
timezone: None,
})
}
fn resolve_query(
client: &Client,
city: Option<&str>,
allow_interactive_setup: bool,
) -> Result<RamadanQuery> {
if let Some(city) = city {
return Ok(resolve_query_from_city_input(client, city));
}
if let Some(stored_query) = get_stored_query() {
return Ok(stored_query);
}
if allow_interactive_setup && can_prompt_interactively() {
let configured = match run_first_run_setup(client) {
Ok(configured) => configured,
Err(error) => {
let message = error.to_string().to_ascii_lowercase();
if message.contains("interrupted") || message.contains("cancel") {
return Err(anyhow!("SETUP_CANCELLED"));
}
return Err(error);
}
};
if configured {
if let Some(configured_query) = get_stored_query() {
return Ok(configured_query);
}
} else {
return Err(anyhow!("SETUP_CANCELLED"));
}
}
let guessed = guess_location(client).ok_or_else(|| {
anyhow!("Could not detect location. Pass a city like `ramadan-cli \"Lahore\"`.")
})?;
save_auto_detected_setup(&guessed)?;
Ok(with_stored_settings(RamadanQuery {
address: get_address_from_guess(&guessed),
city: Some(guessed.city),
country: Some(guessed.country),
latitude: Some(guessed.latitude),
longitude: Some(guessed.longitude),
method: None,
school: None,
timezone: None,
}))
}
fn fetch_ramadan_day(
client: &Client,
query: &RamadanQuery,
date: Option<NaiveDate>,
) -> Result<PrayerData> {
let mut errors: Vec<String> = Vec::new();
let by_address = fetch_timings_by_address(
client,
&FetchByAddressOptions {
address: query.address.clone(),
method: query.method,
school: query.school,
date,
},
);
match by_address {
Ok(day) => return Ok(day),
Err(error) => errors.push(format!("timingsByAddress failed: {}", error)),
}
if let (Some(city), Some(country)) = (&query.city, &query.country) {
let by_city = fetch_timings_by_city(
client,
&FetchByCityOptions {
city: city.clone(),
country: country.clone(),
method: query.method,
school: query.school,
date,
},
);
match by_city {
Ok(day) => return Ok(day),
Err(error) => errors.push(format!("timingsByCity failed: {}", error)),
}
}
if let (Some(latitude), Some(longitude)) = (query.latitude, query.longitude) {
let by_coords = fetch_timings_by_coords(
client,
&FetchByCoordsOptions {
latitude,
longitude,
method: query.method,
school: query.school,
timezone: query.timezone.clone(),
date,
},
);
match by_coords {
Ok(day) => return Ok(day),
Err(error) => errors.push(format!("timingsByCoords failed: {}", error)),
}
}
Err(anyhow!(
"Could not fetch prayer times. {}",
errors.join(" | ")
))
}
fn fetch_ramadan_calendar(
client: &Client,
query: &RamadanQuery,
year: i64,
) -> Result<Vec<PrayerData>> {
let mut errors: Vec<String> = Vec::new();
let by_address = fetch_hijri_calendar_by_address(
client,
&FetchHijriCalendarByAddressOptions {
address: query.address.clone(),
year,
month: 9,
method: query.method,
school: query.school,
},
);
match by_address {
Ok(days) => return Ok(days),
Err(error) => errors.push(format!("hijriCalendarByAddress failed: {}", error)),
}
if let (Some(city), Some(country)) = (&query.city, &query.country) {
let by_city = fetch_hijri_calendar_by_city(
client,
&FetchHijriCalendarByCityOptions {
city: city.clone(),
country: country.clone(),
year,
month: 9,
method: query.method,
school: query.school,
},
);
match by_city {
Ok(days) => return Ok(days),
Err(error) => errors.push(format!("hijriCalendarByCity failed: {}", error)),
}
}
Err(anyhow!(
"Could not fetch Ramadan calendar. {}",
errors.join(" | ")
))
}
fn fetch_custom_ramadan_days(
client: &Client,
query: &RamadanQuery,
first_roza_date: NaiveDate,
) -> Result<Vec<PrayerData>> {
let mut days = Vec::with_capacity(30);
for index in 0..30 {
let day_date = add_days(first_roza_date, index as i64);
days.push(fetch_ramadan_day(client, query, Some(day_date))?);
}
Ok(days)
}
fn get_row_by_roza_number(days: &[PrayerData], roza_number: usize) -> Result<RamadanRow> {
let Some(day) = days.get(roza_number.saturating_sub(1)) else {
return Err(anyhow!("Could not find roza {roza_number} timings."));
};
Ok(to_ramadan_row(day, roza_number))
}
fn get_day_by_roza_number(days: &[PrayerData], roza_number: usize) -> Result<PrayerData> {
let Some(day) = days.get(roza_number.saturating_sub(1)) else {
return Err(anyhow!("Could not find roza {roza_number} timings."));
};
Ok(day.clone())
}
fn get_hijri_year_from_roza_number(
days: &[PrayerData],
roza_number: usize,
fallback_year: i64,
) -> i64 {
days.get(roza_number.saturating_sub(1))
.and_then(|day| day.date.hijri.year.parse::<i64>().ok())
.unwrap_or(fallback_year)
}
fn set_row_annotation(
annotations: &mut HashMap<usize, RowAnnotationKind>,
roza: i64,
kind: RowAnnotationKind,
) {
if (1..=30).contains(&roza) {
annotations.insert(roza as usize, kind);
}
}
fn get_all_mode_row_annotations(
today: &PrayerData,
today_gregorian_date: NaiveDate,
target_year: i64,
configured_first_roza_date: Option<NaiveDate>,
) -> HashMap<usize, RowAnnotationKind> {
let mut annotations: HashMap<usize, RowAnnotationKind> = HashMap::new();
if let Some(first_roza_date) = configured_first_roza_date {
let current_roza = get_roza_number_from_start_date(first_roza_date, today_gregorian_date);
if current_roza < 1 {
set_row_annotation(&mut annotations, 1, RowAnnotationKind::Next);
return annotations;
}
set_row_annotation(&mut annotations, current_roza, RowAnnotationKind::Current);
set_row_annotation(&mut annotations, current_roza + 1, RowAnnotationKind::Next);
return annotations;
}
let today_hijri_year = today.date.hijri.year.parse::<i64>().unwrap_or(0);
let is_ramadan_now = today.date.hijri.month.number == 9 && today_hijri_year == target_year;
if !is_ramadan_now {
set_row_annotation(&mut annotations, 1, RowAnnotationKind::Next);
return annotations;
}
let current_roza = get_roza_number_from_hijri_day(today) as i64;
set_row_annotation(&mut annotations, current_roza, RowAnnotationKind::Current);
set_row_annotation(&mut annotations, current_roza + 1, RowAnnotationKind::Next);
annotations
}
fn print_text_output(
output: &RamadanOutput,
plain: bool,
highlight: Option<&HighlightState>,
row_annotations: &HashMap<usize, RowAnnotationKind>,
) {
let title = match output.mode.as_str() {
"all" => format!("Ramadan {} (All Days)", output.hijri_year),
"number" => format!(
"Roza {} Sehar/Iftar",
output.rows.first().map(|r| r.roza).unwrap_or_default()
),
_ => "Today Sehar/Iftar".to_string(),
};
if plain {
println!("RAMADAN CLI");
} else {
println!("{}", get_banner());
}
println!("{}", ramadan_green(&format!(" {title}")));
println!(" 📍 {}", output.location);
println!();
print_table(&output.rows, row_annotations);
println!();
if let Some(highlight) = highlight {
println!(" {} {}", ramadan_green("Status:"), highlight.current);
println!(
" {} {} in {}",
ramadan_green("Up next:"),
highlight.next,
highlight.countdown
);
println!();
}
println!(" Sehar uses Fajr. Iftar uses Maghrib.");
println!();
}
pub fn ramadan_command(
client: &Client,
opts: &RamadanCommandOptions,
) -> Result<Option<RamadanOutput>> {
let configured_first_roza_date = get_configured_first_roza_date(opts)?;
let query = resolve_query(client, opts.city.as_deref(), !opts.json)?;
let today = fetch_ramadan_day(client, &query, None)?;
let today_gregorian_date = parse_gregorian_date(&today.date.gregorian.date)
.ok_or_else(|| anyhow!("Could not parse Gregorian date from prayer response."))?;
let target_year = get_target_ramadan_year(&today);
let has_custom_first_roza_date = configured_first_roza_date.is_some();
if opts.all && opts.roza_number.is_some() {
return Err(anyhow!("Use either --all or --number, not both."));
}
if let Some(roza_number) = opts.roza_number {
let (row, selected_day, hijri_year) = if has_custom_first_roza_date {
let first_roza_date = configured_first_roza_date
.ok_or_else(|| anyhow!("Could not determine first roza date."))?;
let custom_days = fetch_custom_ramadan_days(client, &query, first_roza_date)?;
(
get_row_by_roza_number(&custom_days, roza_number)?,
get_day_by_roza_number(&custom_days, roza_number)?,
get_hijri_year_from_roza_number(&custom_days, roza_number, target_year),
)
} else {
let calendar = fetch_ramadan_calendar(client, &query, target_year)?;
(
get_row_by_roza_number(&calendar, roza_number)?,
get_day_by_roza_number(&calendar, roza_number)?,
get_hijri_year_from_roza_number(&calendar, roza_number, target_year),
)
};
let output = RamadanOutput {
mode: "number".to_string(),
location: query.address,
hijri_year,
rows: vec![row],
};
if opts.json {
return Ok(Some(output));
}
let annotations = HashMap::new();
let highlight = get_highlight_state(&selected_day);
print_text_output(&output, opts.plain, highlight.as_ref(), &annotations);
return Ok(None);
}
if !opts.all {
let (row, highlight_day, output_hijri_year) = if has_custom_first_roza_date {
let first_roza_date = configured_first_roza_date
.ok_or_else(|| anyhow!("Could not determine first roza date."))?;
let roza_number =
get_roza_number_from_start_date(first_roza_date, today_gregorian_date);
if roza_number < 1 {
let first_roza_day = fetch_ramadan_day(client, &query, Some(first_roza_date))?;
let hijri_year = first_roza_day
.date
.hijri
.year
.parse::<i64>()
.unwrap_or(target_year);
(
to_ramadan_row(&first_roza_day, 1),
first_roza_day,
hijri_year,
)
} else {
let hijri_year = today.date.hijri.year.parse::<i64>().unwrap_or(target_year);
(
to_ramadan_row(&today, roza_number as usize),
today.clone(),
hijri_year,
)
}
} else if today.date.hijri.month.number == 9 {
(
to_ramadan_row(&today, get_roza_number_from_hijri_day(&today)),
today.clone(),
target_year,
)
} else {
let calendar = fetch_ramadan_calendar(client, &query, target_year)?;
let first_day = calendar
.first()
.ok_or_else(|| anyhow!("Could not find the first day of Ramadan."))?
.clone();
let hijri_year = first_day
.date
.hijri
.year
.parse::<i64>()
.unwrap_or(target_year);
(to_ramadan_row(&first_day, 1), first_day, hijri_year)
};
let output = RamadanOutput {
mode: "today".to_string(),
location: query.address,
hijri_year: output_hijri_year,
rows: vec![row],
};
if opts.json {
return Ok(Some(output));
}
let highlight = get_highlight_state(&highlight_day);
let annotations = HashMap::new();
print_text_output(&output, opts.plain, highlight.as_ref(), &annotations);
return Ok(None);
}
let (rows, hijri_year) = if has_custom_first_roza_date {
let first_roza_date = configured_first_roza_date
.ok_or_else(|| anyhow!("Could not determine first roza date."))?;
let custom_days = fetch_custom_ramadan_days(client, &query, first_roza_date)?;
let rows = custom_days
.iter()
.enumerate()
.map(|(index, day)| to_ramadan_row(day, index + 1))
.collect::<Vec<_>>();
let hijri_year = custom_days
.first()
.and_then(|day| day.date.hijri.year.parse::<i64>().ok())
.unwrap_or(target_year);
(rows, hijri_year)
} else {
let calendar = fetch_ramadan_calendar(client, &query, target_year)?;
let rows = calendar
.iter()
.enumerate()
.map(|(index, day)| to_ramadan_row(day, index + 1))
.collect::<Vec<_>>();
(rows, target_year)
};
let output = RamadanOutput {
mode: "all".to_string(),
location: query.address,
hijri_year,
rows,
};
if opts.json {
return Ok(Some(output));
}
let row_annotations = get_all_mode_row_annotations(
&today,
today_gregorian_date,
target_year,
configured_first_roza_date,
);
let highlight = get_highlight_state(&today);
print_text_output(&output, opts.plain, highlight.as_ref(), &row_annotations);
Ok(None)
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use serde_json::json;
use crate::api::PrayerData;
use super::{
RamadanOutput, get_json_error_code, get_roza_number_from_start_date,
get_target_ramadan_year, normalize_city_alias, to_12_hour_time,
};
fn sample_prayer_data(hijri_month: i64, hijri_year: &str) -> PrayerData {
serde_json::from_value(json!({
"timings": {
"Fajr": "05:30",
"Sunrise": "06:45",
"Dhuhr": "12:30",
"Asr": "15:45",
"Sunset": "18:15",
"Maghrib": "18:15",
"Isha": "19:45",
"Imsak": "05:20",
"Midnight": "00:00",
"Firstthird": "22:00",
"Lastthird": "02:00"
},
"date": {
"readable": "01 Feb 2026",
"timestamp": "1738368000",
"hijri": {
"date": "03-09-1447",
"day": "3",
"month": {
"number": hijri_month,
"en": "Ramadan",
"ar": "رَمَضَان"
},
"year": hijri_year,
"weekday": {
"en": "Sunday",
"ar": "الأحد"
}
},
"gregorian": {
"date": "01-02-2026",
"day": "01",
"month": {
"number": 2,
"en": "February"
},
"year": "2026",
"weekday": {
"en": "Sunday"
}
}
},
"meta": {
"latitude": 31.5204,
"longitude": 74.3587,
"timezone": "Asia/Karachi",
"method": {
"id": 1,
"name": "Karachi"
},
"school": {
"id": 1,
"name": "Hanafi"
}
}
}))
.expect("sample prayer data should deserialize")
}
#[test]
fn get_roza_number_from_start_date_handles_boundaries() {
let first_roza_date = NaiveDate::from_ymd_opt(2026, 2, 18).expect("valid date");
assert_eq!(
get_roza_number_from_start_date(
first_roza_date,
NaiveDate::from_ymd_opt(2026, 2, 18).expect("valid")
),
1
);
assert_eq!(
get_roza_number_from_start_date(
first_roza_date,
NaiveDate::from_ymd_opt(2026, 2, 20).expect("valid")
),
3
);
assert_eq!(
get_roza_number_from_start_date(
first_roza_date,
NaiveDate::from_ymd_opt(2026, 2, 17).expect("valid")
),
0
);
}
#[test]
fn time_formatting_matches_expected_values() {
assert_eq!(to_12_hour_time("05:48"), "5:48 AM");
assert_eq!(to_12_hour_time("17:38"), "5:38 PM");
assert_eq!(to_12_hour_time("17:38 (PST)"), "5:38 PM");
assert_eq!(to_12_hour_time("not-a-time"), "not-a-time");
}
#[test]
fn alias_normalization_matches_cli_contract() {
assert_eq!(normalize_city_alias("sf"), "San Francisco");
assert_eq!(normalize_city_alias("SF"), "San Francisco");
assert_eq!(normalize_city_alias("lahore"), "lahore");
}
#[test]
fn json_error_codes_are_stable() {
assert_eq!(
get_json_error_code("Could not fetch prayer times. timingsByAddress failed"),
"PRAYER_TIMES_FETCH_FAILED"
);
assert_eq!(
get_json_error_code("Use either --all or --number, not both."),
"INVALID_FLAG_COMBINATION"
);
}
#[test]
fn target_ramadan_year_matches_ts_logic() {
assert_eq!(
get_target_ramadan_year(&sample_prayer_data(8, "1447")),
1447
);
assert_eq!(
get_target_ramadan_year(&sample_prayer_data(10, "1447")),
1448
);
}
#[test]
fn json_output_uses_hijri_year_camel_case() {
let output = RamadanOutput {
mode: "today".to_string(),
location: "Lahore, Pakistan".to_string(),
hijri_year: 1447,
rows: vec![],
};
let value = serde_json::to_value(output).expect("output should serialize");
assert!(value.get("hijriYear").is_some());
assert!(value.get("hijri_year").is_none());
}
}