shaum_rules/
rules.rs

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
11/// Moon sighting adjustment provider.
12/// 
13/// When the `async` feature is enabled, returns a pinned boxed future.
14/// Otherwise, returns a synchronous result.
15pub 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/// Fixed day offset for all dates.
28#[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/// No adjustment (use astronomical calculation).
53#[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/// Remote moon provider fetching adjustment from an API.
73#[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
120/// Interface for calculating sunset time.
121pub trait SunsetProvider: std::fmt::Debug + Send + Sync {
122    /// Returns the sunset timestamp for a given date and coordinate.
123    fn get_sunset(&self, date: NaiveDate, coords: GeoCoordinate) -> Result<DateTime<Utc>, ShaumError>;
124}
125
126/// Default sunset calculator using VSOP87 astronomy engine.
127#[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        // Use the astronomy engine for accurate sunset calculation
133        shaum_astronomy::visibility::estimate_sunset(date, coords)
134    }
135}
136
137/// Custom rule trait.
138pub 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/// Rule engine configuration.
144#[derive(Debug, Serialize)] // Removing Deserialize because dynamic traits (SunsetProvider) are hard to deserialize without specific logic
145pub struct RuleContext {
146    /// Hijri day offset. Clamped to [-30, 30].
147    pub adjustment: i64,
148    pub madhab: Madhab,
149    pub daud_strategy: DaudStrategy,
150    pub strict: bool,
151    /// Moon visibility criteria for hilal observation.
152    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), // Resetting provider on clone as we can't clone trait object easily without `dyn Clone`
169        }
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    /// Sets moon visibility criteria.
216    pub fn visibility_criteria(mut self, criteria: VisibilityCriteria) -> Self {
217        self.visibility_criteria = criteria;
218        self
219    }
220}
221
222/// Builder with validation for `RuleContext`.
223#[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    /// Enables strict adjustment bounds [-2, 2].
248    pub fn strict_adjustment(mut self, strict: bool) -> Self { self.strict_adjustment = strict; self }
249
250    /// Sets moon visibility criteria.
251    pub fn visibility_criteria(mut self, criteria: VisibilityCriteria) -> Self { 
252        self.visibility_criteria = Some(criteria); 
253        self 
254    }
255
256    /// Builds and validates.
257    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
278/// Analyzes fasting status for a specific moment in time.
279/// 
280/// * `datetime`: The checking time in UTC.
281/// * `context`: The rule configuration.
282/// * `coords`: Optional coordinates for sunset-aware calculation.
283pub 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    // 1. Determine Effective Date (Maghrib Logic)
291    let mut effective_date = datetime.date_naive();
292    
293    if let Some(c) = coords {
294        // Use provider from context
295        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    // 2. Strict Mode Check (handled by to_hijri implicitly returning error if out of range)
304    // But we check bounds here too to be nice?
305    // Actually to_hijri will error out.
306    // If strict is OFF, we might want to handle error "gracefully" if it's purely a range issue?
307    // But the prompt says "NO PANICS: Remove unwrap... Use Result propagation".
308    // So if to_hijri fails, analyze fails.
309    
310    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    // This propagates error.
316    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    // --- Rules ---
327
328    // Haram Priority
329    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    // Wajib
348    if h_month == MONTH_RAMADHAN {
349        types.push(FastingType::RAMADHAN);
350        traces.push(RuleTrace::simple(TraceCode::Ramadhan));
351        status = FastingStatus::Wajib;
352    }
353
354    // Sunnah Muakkadah
355    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    // Sunnah
368    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    // Makruh Checks
405    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    // Custom rules evaluation
422    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
433/// Checks fasting status for a given date.
434/// Defaults to Noon UTC.
435/// 
436/// Returns `Result<FastingAnalysis, ShaumError>` (Changed from infallible).
437pub 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