Skip to main content

amlich_core/
lib.rs

1// amlich-core - Vietnamese Lunar Calendar Core Library
2//
3// This library provides comprehensive Vietnamese lunar calendar calculations including:
4// - Solar ↔ Lunar date conversion
5// - Can Chi (Heavenly Stems & Earthly Branches) calculations
6// - Tiết Khí (24 Solar Terms)
7// - Giờ Hoàng Đạo (Auspicious Hours)
8// - Vietnamese holidays and festivals
9
10pub mod almanac;
11pub mod canchi;
12pub mod gio_hoang_dao;
13pub mod holiday_data;
14pub mod holidays;
15pub mod insight_data;
16pub mod julian;
17pub mod lunar;
18pub mod sun;
19pub mod tietkhi;
20pub mod types;
21
22// Re-export main types
23pub use crate::almanac::thap_than::get_thap_than;
24pub use crate::almanac::tu_menh::{compute_kua, Gender, KuaGroup, KuaResult};
25pub use crate::almanac::types::{HeavenlyStem, ThapThanLabel, ThapThanResult};
26pub use types::*;
27
28use crate::almanac::calc::calculate_day_fortune;
29use crate::almanac::data::get_ruleset;
30use crate::almanac::recommendation::{
31    synthesize_daily_recommendations, synthesize_daily_recommendations_with_layers,
32    DailyRecommendations, RecommendationSynthesisContext,
33};
34use crate::almanac::types::DayFortune;
35use canchi::{get_day_canchi, get_month_canchi, get_year_canchi};
36use gio_hoang_dao::{get_gio_hoang_dao, GioHoangDao};
37use julian::jd_from_date;
38use lunar::{convert_solar_to_lunar, LunarDate};
39use tietkhi::{get_tiet_khi, SolarTerm};
40
41#[derive(Debug, Clone)]
42pub struct SolarDate {
43    pub day: i32,
44    pub month: i32,
45    pub year: i32,
46    pub day_of_week: usize,
47}
48
49#[derive(Debug, Clone)]
50pub struct CanChiSet {
51    pub day: CanChi,
52    pub month: CanChi,
53    pub year: CanChi,
54}
55
56#[derive(Debug, Clone)]
57pub struct DayContext {
58    pub solar: SolarDate,
59    pub lunar: LunarDate,
60    pub jd: i32,
61    pub weekday_index: usize,
62    pub canchi: CanChiSet,
63    pub tiet_khi: SolarTerm,
64    pub gio_hoang_dao: GioHoangDao,
65}
66
67#[derive(Debug, Clone)]
68pub struct DaySnapshot {
69    pub ruleset_id: String,
70    pub ruleset_version: String,
71    pub profile: String,
72    pub context: DayContext,
73    pub day_fortune: DayFortune,
74    pub daily_recommendations: DailyRecommendations,
75    pub contextual_recommendations: Option<DailyRecommendations>,
76}
77
78#[derive(Debug, Clone, Default)]
79struct SnapshotRequest<'a> {
80    ruleset_id: Option<&'a str>,
81    event_kind: Option<&'a str>,
82    enabled_pack_ids: &'a [&'a str],
83}
84
85pub fn compute_day_context(day: i32, month: i32, year: i32, time_zone: f64) -> DayContext {
86    let jd = jd_from_date(day, month, year);
87    let lunar = convert_solar_to_lunar(day, month, year, time_zone);
88    let weekday_index = ((jd + 1) % 7) as usize;
89    let day_canchi = get_day_canchi(jd);
90    let month_canchi = get_month_canchi(lunar.month, lunar.year, lunar.is_leap);
91    let year_canchi = get_year_canchi(lunar.year);
92    let tiet_khi = get_tiet_khi(jd, time_zone);
93    let gio_hoang_dao = get_gio_hoang_dao(day_canchi.chi_index);
94
95    DayContext {
96        solar: SolarDate {
97            day,
98            month,
99            year,
100            day_of_week: weekday_index,
101        },
102        lunar,
103        jd,
104        weekday_index,
105        canchi: CanChiSet {
106            day: day_canchi,
107            month: month_canchi,
108            year: year_canchi,
109        },
110        tiet_khi,
111        gio_hoang_dao,
112    }
113}
114
115pub fn calculate_day_snapshot(day: i32, month: i32, year: i32) -> DaySnapshot {
116    calculate_day_snapshot_with_timezone(day, month, year, VIETNAM_TIMEZONE)
117}
118
119pub fn calculate_day_snapshot_with_recommendation_request(
120    day: i32,
121    month: i32,
122    year: i32,
123    time_zone: f64,
124    ruleset_id: Option<&str>,
125    event_kind: Option<&str>,
126    enabled_pack_ids: &[&str],
127) -> Result<DaySnapshot, String> {
128    calculate_day_snapshot_internal(
129        day,
130        month,
131        year,
132        time_zone,
133        SnapshotRequest {
134            ruleset_id,
135            event_kind,
136            enabled_pack_ids,
137        },
138    )
139}
140
141pub fn calculate_day_snapshot_with_timezone(
142    day: i32,
143    month: i32,
144    year: i32,
145    time_zone: f64,
146) -> DaySnapshot {
147    calculate_day_snapshot_internal(day, month, year, time_zone, SnapshotRequest::default())
148        .expect("default recommendation request should be valid")
149}
150
151fn calculate_day_snapshot_internal(
152    day: i32,
153    month: i32,
154    year: i32,
155    time_zone: f64,
156    recommendation_request: SnapshotRequest<'_>,
157) -> Result<DaySnapshot, String> {
158    let ruleset_entry = match recommendation_request.ruleset_id {
159        Some(ruleset_id) => Some(get_ruleset(ruleset_id).map_err(|err| err.to_string())?),
160        None => None,
161    };
162
163    let context = compute_day_context(day, month, year, time_zone);
164    let day_fortune = calculate_day_fortune(
165        context.jd,
166        &context.canchi.day,
167        context.lunar.day,
168        context.lunar.month,
169        &context.canchi.year.can,
170        &context.tiet_khi.name,
171    );
172    let recommendation_context = RecommendationSynthesisContext {
173        day_chi: &context.canchi.day.chi,
174        day_fortune: &day_fortune,
175        gio_hoang_dao: Some(&context.gio_hoang_dao),
176        tiet_khi_name: Some(&context.tiet_khi.name),
177        profile_id: ruleset_entry.map(|entry| entry.descriptor.profile),
178        event_kind: None,
179        enabled_pack_ids: &[],
180    };
181    let daily_recommendations = synthesize_daily_recommendations(&recommendation_context);
182    let contextual_recommendations = if recommendation_request.event_kind.is_some()
183        || !recommendation_request.enabled_pack_ids.is_empty()
184    {
185        let contextual_context = RecommendationSynthesisContext {
186            day_chi: &context.canchi.day.chi,
187            day_fortune: &day_fortune,
188            gio_hoang_dao: Some(&context.gio_hoang_dao),
189            tiet_khi_name: Some(&context.tiet_khi.name),
190            profile_id: Some("contextual"),
191            event_kind: recommendation_request.event_kind,
192            enabled_pack_ids: recommendation_request.enabled_pack_ids,
193        };
194        Some(
195            synthesize_daily_recommendations_with_layers(&contextual_context, &[])
196                .map_err(|err| err.to_string())?,
197        )
198    } else {
199        None
200    };
201
202    Ok(DaySnapshot {
203        ruleset_id: day_fortune.ruleset_id.clone(),
204        ruleset_version: day_fortune.ruleset_version.clone(),
205        profile: day_fortune.profile.clone(),
206        context,
207        day_fortune,
208        daily_recommendations,
209        contextual_recommendations,
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn compute_day_context_exposes_structured_calendar_facts() {
219        let context = compute_day_context(10, 2, 2024, VIETNAM_TIMEZONE);
220
221        assert_eq!(context.solar.day, 10);
222        assert_eq!(context.solar.month, 2);
223        assert_eq!(context.solar.year, 2024);
224        assert_eq!(context.weekday_index, context.solar.day_of_week);
225        assert_eq!(context.lunar.day, 1);
226        assert_eq!(context.lunar.month, 1);
227        assert_eq!(context.lunar.year, 2024);
228        assert!(!context.lunar.is_leap);
229        assert_eq!(context.canchi.day.full, "Giáp Thìn");
230        assert_eq!(context.canchi.year.full, "Giáp Thìn");
231        assert_eq!(context.tiet_khi.name, "Lập Xuân");
232        assert_eq!(context.gio_hoang_dao.good_hour_count, 6);
233    }
234
235    #[test]
236    fn compute_day_context_supports_custom_timezone() {
237        let context = compute_day_context(10, 2, 2024, 8.0);
238
239        assert_eq!(context.solar.day, 10);
240        assert_eq!(context.solar.month, 2);
241        assert_eq!(context.solar.year, 2024);
242    }
243
244    #[test]
245    fn calculate_day_snapshot_keeps_recommendations_and_ruleset_metadata() {
246        let snapshot = calculate_day_snapshot(10, 2, 2024);
247
248        assert_eq!(snapshot.ruleset_id, "vn_baseline_v1");
249        assert_eq!(snapshot.ruleset_version, "v1");
250        assert_eq!(snapshot.profile, "baseline");
251        assert!(!snapshot.daily_recommendations.activities.is_empty());
252        assert!(!snapshot.daily_recommendations.summary_vi.is_empty());
253        assert_eq!(
254            snapshot.daily_recommendations.ruleset_id,
255            snapshot.ruleset_id
256        );
257        assert_eq!(
258            snapshot.daily_recommendations.ruleset_version,
259            snapshot.ruleset_version
260        );
261        assert_eq!(snapshot.daily_recommendations.profile, snapshot.profile);
262        assert!(snapshot.contextual_recommendations.is_none());
263    }
264
265    #[test]
266    fn calculate_day_snapshot_emits_contextual_recommendations_when_requested() {
267        let snapshot = calculate_day_snapshot_with_recommendation_request(
268            10,
269            2,
270            2024,
271            VIETNAM_TIMEZONE,
272            None,
273            Some("contract_signing"),
274            &[],
275        )
276        .expect("contextual day snapshot");
277
278        let contextual = snapshot
279            .contextual_recommendations
280            .as_ref()
281            .expect("contextual recommendations");
282        assert!(contextual
283            .activities
284            .iter()
285            .find(|activity| activity.activity_id
286                == crate::almanac::recommendation::ActivityId::ContractAgreement)
287            .expect("contract activity")
288            .reasons
289            .iter()
290            .any(|reason| reason.rule_id == "layer.product_rule.event_kind.contract_signing"));
291    }
292
293    #[test]
294    fn calculate_day_snapshot_rejects_unknown_pack_ids() {
295        let err = calculate_day_snapshot_with_recommendation_request(
296            10,
297            2,
298            2024,
299            VIETNAM_TIMEZONE,
300            None,
301            None,
302            &["pack.unknown.v1"],
303        )
304        .expect_err("unknown pack should fail");
305
306        assert_eq!(err, "unknown recommendation pack id: pack.unknown.v1");
307    }
308}