1use chrono::{Datelike, NaiveDate, Weekday, DateTime, Utc, TimeZone};
2use shaum_calendar::{to_hijri, HIJRI_MIN_YEAR, HIJRI_MAX_YEAR};
3use shaum_types::ShaumError;
4use shaum_types::{FastingAnalysis, FastingStatus, FastingType, Madhab, DaudStrategy, RuleTrace, TraceCode, GeoCoordinate, VisibilityCriteria, TracePayload};
5use crate::constants::*;
6use serde::Serialize;
7#[cfg(feature = "async")]
8use serde::Deserialize;
9use smallvec::SmallVec;
10
11pub trait MoonProvider: std::fmt::Debug + Send + Sync {
16 #[cfg(feature = "async")]
17 fn get_adjustment(
18 &self,
19 date: NaiveDate,
20 coords: Option<GeoCoordinate>,
21 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i64, ShaumError>> + Send + '_>>;
22
23 #[cfg(not(feature = "async"))]
24 fn get_adjustment(&self, date: NaiveDate, coords: Option<GeoCoordinate>) -> Result<i64, ShaumError>;
25}
26
27#[derive(Debug, Clone, Copy, Default)]
29pub struct FixedAdjustment(pub i64);
30
31impl FixedAdjustment {
32 pub fn new(offset: i64) -> Self { Self(offset.clamp(-30, 30)) }
33}
34
35impl MoonProvider for FixedAdjustment {
36 #[cfg(feature = "async")]
37 fn get_adjustment(
38 &self,
39 _date: NaiveDate,
40 _coords: Option<GeoCoordinate>,
41 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i64, ShaumError>> + Send + '_>> {
42 let val = self.0;
43 Box::pin(async move { Ok(val) })
44 }
45
46 #[cfg(not(feature = "async"))]
47 fn get_adjustment(&self, _date: NaiveDate, _coords: Option<GeoCoordinate>) -> Result<i64, ShaumError> {
48 Ok(self.0)
49 }
50}
51
52#[derive(Debug, Clone, Copy, Default)]
54pub struct NoAdjustment;
55
56impl MoonProvider for NoAdjustment {
57 #[cfg(feature = "async")]
58 fn get_adjustment(
59 &self,
60 _date: NaiveDate,
61 _coords: Option<GeoCoordinate>,
62 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i64, ShaumError>> + Send + '_>> {
63 Box::pin(async move { Ok(0) })
64 }
65
66 #[cfg(not(feature = "async"))]
67 fn get_adjustment(&self, _date: NaiveDate, _coords: Option<GeoCoordinate>) -> Result<i64, ShaumError> {
68 Ok(0)
69 }
70}
71
72#[cfg(feature = "async")]
74#[derive(Debug, Clone)]
75pub struct RemoteMoonProvider {
76 endpoint: String,
77 client: reqwest::Client,
78}
79
80#[cfg(feature = "async")]
81impl RemoteMoonProvider {
82 pub fn new(endpoint: impl Into<String>) -> Self {
83 Self {
84 endpoint: endpoint.into(),
85 client: reqwest::Client::new(),
86 }
87 }
88}
89
90#[cfg(feature = "async")]
91impl MoonProvider for RemoteMoonProvider {
92 fn get_adjustment(
93 &self,
94 _date: NaiveDate,
95 _coords: Option<GeoCoordinate>,
96 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i64, ShaumError>> + Send + '_>> {
97 let endpoint = self.endpoint.clone();
98 let client = self.client.clone();
99
100 Box::pin(async move {
101 #[derive(Deserialize)]
102 struct AdjustmentResponse {
103 adjustment: i64,
104 }
105
106 let resp = client.get(&endpoint)
107 .send()
108 .await
109 .map_err(|e| ShaumError::NetworkError(e.to_string()))?;
110
111 let data = resp.json::<AdjustmentResponse>()
112 .await
113 .map_err(|e| ShaumError::NetworkError(e.to_string()))?;
114
115 Ok(data.adjustment)
116 })
117 }
118}
119
120pub trait SunsetProvider: std::fmt::Debug + Send + Sync {
122 fn get_sunset(&self, date: NaiveDate, coords: GeoCoordinate) -> Result<DateTime<Utc>, ShaumError>;
124}
125
126#[derive(Debug, Default, Clone, Copy)]
128pub struct DefaultSunsetProvider;
129
130impl SunsetProvider for DefaultSunsetProvider {
131 fn get_sunset(&self, date: NaiveDate, coords: GeoCoordinate) -> Result<DateTime<Utc>, ShaumError> {
132 shaum_astronomy::visibility::estimate_sunset(date, coords)
134 }
135}
136
137pub trait CustomFastingRule: std::fmt::Debug + Send + Sync {
139 fn evaluate(&self, date: NaiveDate, hijri_year: usize, hijri_month: usize, hijri_day: usize)
140 -> Option<(FastingStatus, FastingType)>;
141}
142
143#[derive(Debug, Serialize)] pub struct RuleContext {
146 pub adjustment: i64,
148 pub madhab: Madhab,
149 pub daud_strategy: DaudStrategy,
150 pub strict: bool,
151 pub visibility_criteria: VisibilityCriteria,
153 #[serde(skip)]
154 pub custom_rules: Vec<Box<dyn CustomFastingRule>>,
155 #[serde(skip)]
156 pub sunset_provider: Box<dyn SunsetProvider>,
157}
158
159impl Clone for RuleContext {
160 fn clone(&self) -> Self {
161 Self {
162 adjustment: self.adjustment,
163 madhab: self.madhab,
164 daud_strategy: self.daud_strategy,
165 strict: self.strict,
166 visibility_criteria: self.visibility_criteria,
167 custom_rules: Vec::new(),
168 sunset_provider: Box::new(DefaultSunsetProvider), }
170 }
171}
172
173impl Default for RuleContext {
174 fn default() -> Self {
175 Self {
176 adjustment: 0,
177 madhab: Madhab::default(),
178 daud_strategy: DaudStrategy::default(),
179 strict: false,
180 visibility_criteria: VisibilityCriteria::default(),
181 custom_rules: Vec::new(),
182 sunset_provider: Box::new(DefaultSunsetProvider),
183 }
184 }
185}
186
187impl RuleContext {
188 pub fn new() -> Self { Self::default() }
189
190 pub fn adjustment(mut self, adjustment: i64) -> Self {
191 self.adjustment = adjustment.clamp(-30, 30);
192 self
193 }
194
195 pub fn madhab(mut self, madhab: Madhab) -> Self {
196 self.madhab = madhab;
197 self
198 }
199
200 pub fn daud_strategy(mut self, strategy: DaudStrategy) -> Self {
201 self.daud_strategy = strategy;
202 self
203 }
204
205 pub fn strict(mut self, strict: bool) -> Self {
206 self.strict = strict;
207 self
208 }
209
210 pub fn with_sunset_provider<P: SunsetProvider + 'static>(mut self, provider: P) -> Self {
211 self.sunset_provider = Box::new(provider);
212 self
213 }
214
215 pub fn visibility_criteria(mut self, criteria: VisibilityCriteria) -> Self {
217 self.visibility_criteria = criteria;
218 self
219 }
220}
221
222#[derive(Debug, Default)]
224pub struct RuleContextBuilder {
225 adjustment: Option<i64>,
226 madhab: Option<Madhab>,
227 daud_strategy: Option<DaudStrategy>,
228 custom_rules: Vec<Box<dyn CustomFastingRule>>,
229 sunset_provider: Option<Box<dyn SunsetProvider>>,
230 visibility_criteria: Option<VisibilityCriteria>,
231 strict_adjustment: bool,
232 strict_mode: bool,
233}
234
235impl RuleContextBuilder {
236 pub fn new() -> Self { Self::default() }
237
238 pub fn adjustment(mut self, adjustment: i64) -> Self { self.adjustment = Some(adjustment); self }
239 pub fn madhab(mut self, madhab: Madhab) -> Self { self.madhab = Some(madhab); self }
240 pub fn daud_strategy(mut self, strategy: DaudStrategy) -> Self { self.daud_strategy = Some(strategy); self }
241 pub fn add_custom_rule(mut self, rule: Box<dyn CustomFastingRule>) -> Self { self.custom_rules.push(rule); self }
242 pub fn with_sunset_provider<P: SunsetProvider + 'static>(mut self, provider: P) -> Self {
243 self.sunset_provider = Some(Box::new(provider));
244 self
245 }
246
247 pub fn strict_adjustment(mut self, strict: bool) -> Self { self.strict_adjustment = strict; self }
249
250 pub fn visibility_criteria(mut self, criteria: VisibilityCriteria) -> Self {
252 self.visibility_criteria = Some(criteria);
253 self
254 }
255
256 pub fn build(self) -> Result<RuleContext, ShaumError> {
258 let adjustment = self.adjustment.unwrap_or(0);
259
260 if self.strict_adjustment && (adjustment < -2 || adjustment > 2) {
261 return Err(ShaumError::invalid_config(format!(
262 "Adjustment {} outside strict bounds [-2, 2]", adjustment
263 )));
264 }
265
266 Ok(RuleContext {
267 adjustment: adjustment.clamp(-30, 30),
268 madhab: self.madhab.unwrap_or_default(),
269 daud_strategy: self.daud_strategy.unwrap_or_default(),
270 custom_rules: self.custom_rules,
271 strict: self.strict_mode,
272 visibility_criteria: self.visibility_criteria.unwrap_or_default(),
273 sunset_provider: self.sunset_provider.unwrap_or_else(|| Box::new(DefaultSunsetProvider)),
274 })
275 }
276}
277
278pub fn analyze(
284 datetime: DateTime<Utc>,
285 context: &RuleContext,
286 coords: Option<GeoCoordinate>
287) -> Result<FastingAnalysis, ShaumError> {
288 let mut traces: SmallVec<[RuleTrace; 2]> = SmallVec::new();
289
290 let mut effective_date = datetime.date_naive();
292
293 if let Some(c) = coords {
294 let sunset = context.sunset_provider.get_sunset(effective_date, c)?;
296 if datetime > sunset {
297 effective_date = effective_date.succ_opt()
298 .ok_or_else(|| ShaumError::date_out_of_range(effective_date))?;
299 traces.push(RuleTrace::new(TraceCode::Debug, TracePayload::PostMaghribOffset));
300 }
301 }
302
303 let year = effective_date.year();
311 if (year < HIJRI_MIN_YEAR || year > HIJRI_MAX_YEAR) && context.strict {
312 return Err(ShaumError::date_out_of_range(effective_date));
313 }
314
315 let h_date = to_hijri(effective_date, context.adjustment)?;
317
318 let h_month = h_date.month();
319 let h_day = h_date.day();
320 let h_year = h_date.year() as usize;
321 let weekday = effective_date.weekday();
322
323 let mut types: SmallVec<[FastingType; 2]> = SmallVec::new();
324 let mut status = FastingStatus::Mubah;
325
326 if h_month == MONTH_SHAWWAL && h_day == 1 {
330 types.push(FastingType::EID_AL_FITR);
331 traces.push(RuleTrace::simple(TraceCode::EidAlFitr));
332 return Ok(FastingAnalysis::with_traces(datetime, FastingStatus::Haram, types, (h_year, h_month, h_day), traces));
333 }
334
335 if h_month == MONTH_DHUL_HIJJAH && h_day == 10 {
336 types.push(FastingType::EID_AL_ADHA);
337 traces.push(RuleTrace::simple(TraceCode::EidAlAdha));
338 return Ok(FastingAnalysis::with_traces(datetime, FastingStatus::Haram, types, (h_year, h_month, h_day), traces));
339 }
340
341 if h_month == MONTH_DHUL_HIJJAH && (11..=13).contains(&h_day) {
342 types.push(FastingType::TASHRIQ);
343 traces.push(RuleTrace::simple(TraceCode::Tashriq));
344 return Ok(FastingAnalysis::with_traces(datetime, FastingStatus::Haram, types, (h_year, h_month, h_day), traces));
345 }
346
347 if h_month == MONTH_RAMADHAN {
349 types.push(FastingType::RAMADHAN);
350 traces.push(RuleTrace::simple(TraceCode::Ramadhan));
351 status = FastingStatus::Wajib;
352 }
353
354 if h_month == MONTH_DHUL_HIJJAH && h_day == DAY_ARAFAH {
356 types.push(FastingType::ARAFAH);
357 traces.push(RuleTrace::simple(TraceCode::Arafah));
358 if !status.is_wajib() { status = FastingStatus::SunnahMuakkadah; }
359 }
360
361 if h_month == MONTH_MUHARRAM && h_day == DAY_ASHURA {
362 types.push(FastingType::ASHURA);
363 traces.push(RuleTrace::simple(TraceCode::Ashura));
364 if !status.is_wajib() { status = FastingStatus::SunnahMuakkadah; }
365 }
366
367 if h_month == MONTH_MUHARRAM && h_day == DAY_TASUA {
369 types.push(FastingType::TASUA);
370 traces.push(RuleTrace::simple(TraceCode::Tasua));
371 if !status.is_wajib() && status != FastingStatus::SunnahMuakkadah {
372 status = FastingStatus::Sunnah;
373 }
374 }
375
376 if (13..=15).contains(&h_day) {
377 types.push(FastingType::AYYAMUL_BIDH);
378 traces.push(RuleTrace::simple(TraceCode::AyyamulBidh));
379 if !status.is_wajib() && status < FastingStatus::Sunnah {
380 status = FastingStatus::Sunnah;
381 }
382 }
383
384 match weekday {
385 Weekday::Mon => {
386 types.push(FastingType::MONDAY);
387 traces.push(RuleTrace::simple(TraceCode::Monday));
388 if !status.is_wajib() && status < FastingStatus::Sunnah { status = FastingStatus::Sunnah; }
389 },
390 Weekday::Thu => {
391 types.push(FastingType::THURSDAY);
392 traces.push(RuleTrace::simple(TraceCode::Thursday));
393 if !status.is_wajib() && status < FastingStatus::Sunnah { status = FastingStatus::Sunnah; }
394 },
395 _ => {}
396 }
397
398 if h_month == MONTH_SHAWWAL && h_day > 1 {
399 types.push(FastingType::SHAWWAL);
400 traces.push(RuleTrace::simple(TraceCode::Shawwal));
401 if !status.is_wajib() && status < FastingStatus::Sunnah { status = FastingStatus::Sunnah; }
402 }
403
404 if status == FastingStatus::Mubah {
406 match context.madhab {
407 Madhab::Shafi | Madhab::Hanafi | Madhab::Maliki | Madhab::Hanbali => {
408 if weekday == Weekday::Fri {
409 types.push(FastingType::FRIDAY_EXCLUSIVE);
410 traces.push(RuleTrace::simple(TraceCode::FridaySingledOut));
411 status = FastingStatus::Makruh;
412 } else if weekday == Weekday::Sat {
413 types.push(FastingType::SATURDAY_EXCLUSIVE);
414 traces.push(RuleTrace::simple(TraceCode::SaturdaySingledOut));
415 status = FastingStatus::Makruh;
416 }
417 }
418 }
419 }
420
421 for rule in &context.custom_rules {
423 if let Some((custom_status, custom_type)) = rule.evaluate(effective_date, h_year, h_month, h_day) {
424 types.push(custom_type.clone());
425 traces.push(RuleTrace::new(TraceCode::Custom, TracePayload::CustomReason(custom_type.to_string())));
426 if custom_status > status { status = custom_status; }
427 }
428 }
429
430 Ok(FastingAnalysis::with_traces(datetime, status, types, (h_year, h_month, h_day), traces))
431}
432
433pub fn check(g_date: NaiveDate, context: &RuleContext) -> Result<FastingAnalysis, ShaumError> {
438 let dt = Utc.from_utc_datetime(&g_date.and_hms_opt(12, 0, 0).unwrap());
439 analyze(dt, context, None)
440}
441