1pub 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
22pub 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}