Skip to main content

amlich_api/
v2.rs

1use std::collections::BTreeMap;
2
3use chrono::{Datelike, NaiveDate};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7use crate::dto::{
8    CanChiInfoDto, DailyRecommendationsDto, DateQuery, DayFortuneDto, DayInsightDto,
9    GioHoangDaoDto, KuaResultDto, LunarDto, NaAmResponseDto, SolarDto, ThapThanResultDto,
10    TietKhiDto, UpcomingEventDto,
11};
12
13const SCHEMA_VERSION: &str = "amlich.engine/v1";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Include {
18    Base,
19    CanChi,
20    TietKhi,
21    Hours,
22    Fortune,
23    Insight,
24    Evidence,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ApiMetaDto {
29    pub schema_version: String,
30    pub ruleset_id: String,
31    pub ruleset_version: String,
32    pub profile: String,
33    pub generated_at: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DayBundleDto {
38    pub schema_version: String,
39    pub ruleset_id: String,
40    pub ruleset_version: String,
41    pub profile: String,
42    pub generated_at: String,
43    pub solar: SolarDto,
44    pub lunar: LunarDto,
45    pub jd: i32,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub canchi: Option<CanChiInfoDto>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub tiet_khi: Option<TietKhiDto>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub gio_hoang_dao: Option<GioHoangDaoDto>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub day_fortune: Option<DayFortuneDto>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub daily_recommendations: Option<DailyRecommendationsDto>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub contextual_recommendations: Option<DailyRecommendationsDto>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub insight: Option<DayInsightDto>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub upcoming_events: Vec<UpcomingEventDto>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DayRangeDto {
66    pub schema_version: String,
67    pub ruleset_id: String,
68    pub ruleset_version: String,
69    pub profile: String,
70    pub generated_at: String,
71    pub start: String,
72    pub end: String,
73    pub days: Vec<DayBundleDto>,
74}
75
76impl From<&crate::dto::DayInfoDto> for ApiMetaDto {
77    fn from(info: &crate::dto::DayInfoDto) -> Self {
78        Self {
79            schema_version: SCHEMA_VERSION.to_string(),
80            ruleset_id: info.ruleset_id.clone(),
81            ruleset_version: info.ruleset_version.clone(),
82            profile: info.profile.clone(),
83            generated_at: chrono::Utc::now().to_rfc3339(),
84        }
85    }
86}
87
88impl DayBundleDto {
89    fn from_parts(
90        info: crate::dto::DayInfoDto,
91        insight: Option<DayInsightDto>,
92        includes: &[Include],
93    ) -> Result<Self, String> {
94        let meta = ApiMetaDto::from(&info);
95
96        let upcoming_events =
97            amlich_core::holidays::get_upcoming_events(info.jd, info.solar.year, 14)
98                .into_iter()
99                .map(|e| UpcomingEventDto {
100                    name: e.name,
101                    days_left: e.days_left,
102                    is_lunar: e.is_lunar,
103                })
104                .collect();
105
106        Ok(Self {
107            schema_version: meta.schema_version,
108            ruleset_id: meta.ruleset_id,
109            ruleset_version: meta.ruleset_version,
110            profile: meta.profile,
111            generated_at: meta.generated_at,
112            solar: info.solar,
113            lunar: info.lunar,
114            jd: info.jd,
115            canchi: includes.contains(&Include::CanChi).then_some(info.canchi),
116            tiet_khi: includes
117                .contains(&Include::TietKhi)
118                .then_some(info.tiet_khi),
119            gio_hoang_dao: includes
120                .contains(&Include::Hours)
121                .then_some(info.gio_hoang_dao),
122            day_fortune: includes.contains(&Include::Fortune).then_some(
123                info.day_fortune
124                    .ok_or_else(|| "missing day_fortune in day info".to_string())?,
125            ),
126            daily_recommendations: includes
127                .contains(&Include::Fortune)
128                .then_some(info.daily_recommendations),
129            contextual_recommendations: includes
130                .contains(&Include::Fortune)
131                .then_some(info.contextual_recommendations)
132                .flatten(),
133            insight,
134            upcoming_events,
135        })
136    }
137
138    pub fn meta(&self) -> ApiMetaDto {
139        ApiMetaDto {
140            schema_version: self.schema_version.clone(),
141            ruleset_id: self.ruleset_id.clone(),
142            ruleset_version: self.ruleset_version.clone(),
143            profile: self.profile.clone(),
144            generated_at: self.generated_at.clone(),
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct TietKhiTransitionDto {
151    pub date: String,
152    pub term: TietKhiDto,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TietKhiYearDto {
157    pub year: i32,
158    pub transitions: Vec<TietKhiTransitionDto>,
159}
160
161fn include_set(includes: &[Include]) -> Vec<Include> {
162    if includes.is_empty() {
163        vec![
164            Include::Base,
165            Include::CanChi,
166            Include::TietKhi,
167            Include::Hours,
168            Include::Fortune,
169        ]
170    } else {
171        includes.to_vec()
172    }
173}
174
175fn validate_includes(includes: &[Include]) -> Result<(), String> {
176    if includes.contains(&Include::Evidence) && !includes.contains(&Include::Fortune) {
177        return Err("include=evidence requires include=fortune".to_string());
178    }
179    Ok(())
180}
181
182pub fn get_day_bundle(query: &DateQuery, includes: &[Include]) -> Result<DayBundleDto, String> {
183    let includes = include_set(includes);
184    validate_includes(&includes)?;
185
186    let info = crate::get_day_info(query)?;
187    let insight = if includes.contains(&Include::Insight) {
188        Some(crate::get_day_insight(query)?)
189    } else {
190        None
191    };
192
193    DayBundleDto::from_parts(info, insight, &includes)
194}
195
196pub fn get_day_bundle_for_date(
197    day: i32,
198    month: i32,
199    year: i32,
200    includes: &[Include],
201    timezone: Option<f64>,
202) -> Result<DayBundleDto, String> {
203    let query = DateQuery {
204        day,
205        month,
206        year,
207        timezone,
208        ruleset_id: None,
209        event_kind: None,
210        enabled_pack_ids: vec![],
211    };
212    get_day_bundle(&query, includes)
213}
214
215fn get_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
216    let mut current = value;
217    for segment in path {
218        current = current.get(*segment)?;
219    }
220    Some(current)
221}
222
223fn set_path(root: &mut Map<String, Value>, path: &[&str], leaf: Value) {
224    if path.is_empty() {
225        return;
226    }
227    if path.len() == 1 {
228        root.insert(path[0].to_string(), leaf);
229        return;
230    }
231
232    let entry = root
233        .entry(path[0].to_string())
234        .or_insert_with(|| Value::Object(Map::new()));
235    if let Value::Object(obj) = entry {
236        set_path(obj, &path[1..], leaf);
237    }
238}
239
240pub fn project_fields(value: &Value, fields: &[String]) -> Result<Value, String> {
241    if fields.is_empty() {
242        return Ok(value.clone());
243    }
244
245    let mut projected = Map::new();
246    for field in fields {
247        let segments: Vec<&str> = field.split('.').collect();
248        let leaf = get_path(value, &segments)
249            .ok_or_else(|| format!("unknown field path: {field}"))?
250            .clone();
251        set_path(&mut projected, &segments, leaf);
252    }
253
254    Ok(Value::Object(projected))
255}
256
257pub fn get_day_bundle_projected(
258    query: &DateQuery,
259    includes: &[Include],
260    fields: &[String],
261) -> Result<Value, String> {
262    let bundle = get_day_bundle(query, includes)?;
263    let value = serde_json::to_value(bundle).map_err(|e| format!("serialize failed: {e}"))?;
264    project_fields(&value, fields)
265}
266
267pub fn get_day_range(
268    start: DateQuery,
269    end: DateQuery,
270    includes: &[Include],
271) -> Result<DayRangeDto, String> {
272    let start_date = NaiveDate::from_ymd_opt(start.year, start.month as u32, start.day as u32)
273        .ok_or_else(|| "invalid start date".to_string())?;
274    let end_date = NaiveDate::from_ymd_opt(end.year, end.month as u32, end.day as u32)
275        .ok_or_else(|| "invalid end date".to_string())?;
276
277    if end_date < start_date {
278        return Err("end date must be greater than or equal to start date".to_string());
279    }
280
281    if (end_date - start_date).num_days() > 366 {
282        return Err("date range is too large (max 366 days)".to_string());
283    }
284
285    let mut days = Vec::new();
286    let mut cursor = start_date;
287    while cursor <= end_date {
288        let query = DateQuery {
289            day: cursor.day() as i32,
290            month: cursor.month() as i32,
291            year: cursor.year(),
292            timezone: start.timezone,
293            ruleset_id: start.ruleset_id.clone(),
294            event_kind: start.event_kind.clone(),
295            enabled_pack_ids: start.enabled_pack_ids.clone(),
296        };
297        days.push(get_day_bundle(&query, includes)?);
298        cursor = cursor
299            .succ_opt()
300            .ok_or_else(|| "failed to iterate date range".to_string())?;
301    }
302
303    let first = days
304        .first()
305        .ok_or_else(|| "empty range after processing".to_string())?;
306    Ok(DayRangeDto {
307        schema_version: first.schema_version.clone(),
308        ruleset_id: first.ruleset_id.clone(),
309        ruleset_version: first.ruleset_version.clone(),
310        profile: first.profile.clone(),
311        generated_at: first.generated_at.clone(),
312        start: format!("{}-{:02}-{:02}", start.year, start.month, start.day),
313        end: format!("{}-{:02}-{:02}", end.year, end.month, end.day),
314        days,
315    })
316}
317
318pub fn get_almanac(query: &DateQuery) -> Result<DayFortuneDto, String> {
319    let info = crate::get_day_info(query)?;
320    info.day_fortune
321        .ok_or_else(|| "missing day_fortune for date".to_string())
322}
323
324pub fn get_insight(query: &DateQuery) -> Result<DayInsightDto, String> {
325    crate::get_day_insight(query)
326}
327
328pub fn get_insight_with_profile(
329    query: &DateQuery,
330    birth_year: Option<i32>,
331    birth_month: Option<i32>,
332    birth_day: Option<i32>,
333    gender: Option<amlich_core::almanac::tu_menh::Gender>,
334) -> Result<DayInsightDto, String> {
335    crate::get_day_insight_with_profile(query, birth_year, birth_month, birth_day, gender)
336}
337
338pub fn get_tiet_khi_for_year(year: i32, timezone: Option<f64>) -> Result<TietKhiYearDto, String> {
339    let tz = timezone.or(Some(amlich_core::VIETNAM_TIMEZONE));
340    let mut by_date: BTreeMap<String, TietKhiDto> = BTreeMap::new();
341
342    let mut cursor =
343        NaiveDate::from_ymd_opt(year, 1, 1).ok_or_else(|| "invalid year".to_string())?;
344    let end = NaiveDate::from_ymd_opt(year, 12, 31).ok_or_else(|| "invalid year".to_string())?;
345
346    while cursor <= end {
347        let query = DateQuery {
348            day: cursor.day() as i32,
349            month: cursor.month() as i32,
350            year: cursor.year(),
351            timezone: tz,
352            ruleset_id: None,
353            event_kind: None,
354            enabled_pack_ids: vec![],
355        };
356        let day = crate::get_day_info(&query)?;
357        by_date.insert(cursor.to_string(), day.tiet_khi);
358        cursor = cursor
359            .succ_opt()
360            .ok_or_else(|| "failed to iterate year".to_string())?;
361    }
362
363    let mut transitions = Vec::new();
364    let mut last_name: Option<String> = None;
365    for (date, term) in by_date {
366        let changed = last_name
367            .as_ref()
368            .map(|name| name != &term.name)
369            .unwrap_or(true);
370        if changed {
371            last_name = Some(term.name.clone());
372            transitions.push(TietKhiTransitionDto { date, term });
373        }
374    }
375
376    Ok(TietKhiYearDto { year, transitions })
377}
378
379pub fn convert_solar_to_lunar(query: &DateQuery) -> Result<LunarDto, String> {
380    let info = crate::get_day_info(query)?;
381    Ok(info.lunar)
382}
383
384pub fn convert_lunar_to_solar(
385    day: i32,
386    month: i32,
387    year: i32,
388    leap: bool,
389    timezone: Option<f64>,
390) -> Result<SolarDto, String> {
391    let tz = timezone.unwrap_or(amlich_core::VIETNAM_TIMEZONE);
392    let (solar_day, solar_month, solar_year) =
393        amlich_core::lunar::convert_lunar_to_solar(day, month, year, leap, tz);
394    if (solar_day, solar_month, solar_year) == (0, 0, 0) {
395        return Err("invalid lunar date conversion".to_string());
396    }
397    Ok(crate::get_day_info_for_date(solar_day, solar_month, solar_year)?.solar)
398}
399
400pub fn lookup_na_am_by_index(index: u8) -> NaAmResponseDto {
401    crate::get_na_am_by_index(index)
402}
403
404pub fn lookup_na_am_by_pair(can: &str, chi: &str) -> NaAmResponseDto {
405    crate::get_na_am_by_pair(can, chi)
406}
407
408pub fn lookup_ten_gods(day_can: &str, target_can: &str) -> Result<ThapThanResultDto, String> {
409    let day = amlich_core::HeavenlyStem::try_from(day_can)?;
410    let target = amlich_core::HeavenlyStem::try_from(target_can)?;
411    let result = amlich_core::get_thap_than(day, target);
412    Ok(ThapThanResultDto::from(&result))
413}
414
415pub fn lookup_kua(birth_year: i32, gender: amlich_core::Gender) -> KuaResultDto {
416    let result = amlich_core::compute_kua(birth_year, gender);
417    KuaResultDto::from(&result)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn projection_rejects_unknown_fields() {
426        let value = serde_json::json!({"a": {"b": 1}});
427        let err = project_fields(&value, &["a.c".to_string()]).expect_err("should fail");
428        assert!(err.contains("unknown field path"));
429    }
430
431    #[test]
432    fn projection_keeps_nested_shape() {
433        let value = serde_json::json!({"a": {"b": 1, "c": 2}, "d": 3});
434        let projected =
435            project_fields(&value, &["a.b".to_string(), "d".to_string()]).expect("projected");
436        assert_eq!(projected["a"]["b"], 1);
437        assert_eq!(projected["d"], 3);
438        assert!(projected["a"].get("c").is_none());
439    }
440
441    #[test]
442    fn day_bundle_hides_fortune_when_not_included() {
443        let query = DateQuery {
444            day: 10,
445            month: 2,
446            year: 2024,
447            timezone: None,
448            ruleset_id: None,
449            event_kind: None,
450            enabled_pack_ids: vec![],
451        };
452        let bundle = get_day_bundle(&query, &[Include::Base, Include::CanChi]).expect("bundle");
453        assert!(bundle.day_fortune.is_none());
454        assert!(bundle.daily_recommendations.is_none());
455        assert!(bundle.canchi.is_some());
456    }
457
458    #[test]
459    fn day_bundle_exposes_top_level_metadata() {
460        let query = DateQuery {
461            day: 10,
462            month: 2,
463            year: 2024,
464            timezone: Some(7.0),
465            ruleset_id: None,
466            event_kind: None,
467            enabled_pack_ids: vec![],
468        };
469
470        let bundle = get_day_bundle(&query, &[]).expect("bundle");
471        assert_eq!(bundle.schema_version, SCHEMA_VERSION);
472        assert_eq!(bundle.ruleset_id, "vn_baseline_v1");
473        assert_eq!(bundle.ruleset_version, "v1");
474        assert_eq!(bundle.profile, "baseline");
475        assert!(!bundle.generated_at.is_empty());
476        assert_eq!(bundle.meta().ruleset_id, bundle.ruleset_id);
477    }
478}