Skip to main content

amlich_api/
lib.rs

1mod convert;
2mod dto;
3pub mod v2;
4
5use std::collections::HashMap;
6
7use amlich_core::almanac::data::{get_ruleset, ruleset_registry};
8use amlich_core::almanac::recommendation::pack::recommendation_pack_descriptors;
9use amlich_core::holiday_data::{lunar_festivals, solar_holidays};
10use amlich_core::holidays::get_vietnamese_holidays;
11use amlich_core::insight_data::{
12    all_elements, find_can, find_chi, find_deity_classification_insight, find_deity_insight,
13    find_na_am_insight, find_ten_gods_insight, find_tiet_khi_insight, find_truc_insight,
14    get_day_guidance,
15};
16
17pub use dto::*;
18pub use dto::{NaAmErrorDto, NaAmLookupResultDto, NaAmResponseDto};
19
20/// Convert snake_case to PascalCase (e.g. "ty_kien" -> "TyKien")
21fn snake_to_pascal(s: &str) -> String {
22    s.split('_')
23        .map(|part| {
24            let mut chars = part.chars();
25            match chars.next() {
26                None => String::new(),
27                Some(first) => {
28                    let upper: String = first.to_uppercase().collect();
29                    upper + chars.as_str()
30                }
31            }
32        })
33        .collect()
34}
35
36pub fn get_day_info(query: &DateQuery) -> Result<DayInfoDto, String> {
37    if !(1..=12).contains(&query.month) {
38        return Err("month must be 1-12".to_string());
39    }
40    if !(1..=31).contains(&query.day) {
41        return Err("day must be 1-31".to_string());
42    }
43
44    let normalized_ruleset_id = normalize_ruleset_id(query.ruleset_id.as_deref())?;
45    let normalized_event_kind = normalize_event_kind(query.event_kind.as_deref())?;
46    let enabled_pack_ids = normalize_enabled_pack_ids(&query.enabled_pack_ids)?;
47
48    let tz = query.timezone.unwrap_or(amlich_core::VIETNAM_TIMEZONE);
49    let snapshot = amlich_core::calculate_day_snapshot_with_recommendation_request(
50        query.day,
51        query.month,
52        query.year,
53        tz,
54        normalized_ruleset_id.as_deref(),
55        normalized_event_kind.as_deref(),
56        &enabled_pack_ids,
57    )?;
58    Ok(DayInfoDto::from(&snapshot))
59}
60
61fn normalize_ruleset_id(ruleset_id: Option<&str>) -> Result<Option<String>, String> {
62    let Some(ruleset_id) = ruleset_id.map(str::trim).filter(|id| !id.is_empty()) else {
63        return Ok(None);
64    };
65
66    let entry = get_ruleset(ruleset_id).map_err(|err| err.to_string())?;
67    Ok(Some(entry.descriptor.id.to_string()))
68}
69
70fn normalize_event_kind(event_kind: Option<&str>) -> Result<Option<String>, String> {
71    let Some(event_kind) = event_kind.map(str::trim).filter(|kind| !kind.is_empty()) else {
72        return Ok(None);
73    };
74
75    match event_kind {
76        "contract_signing" | "medical_checkup" | "travel" => {
77            Ok(Some(event_kind.to_string()))
78        }
79        other => Err(format!(
80            "unsupported recommendation event_kind: {other}. supported values: contract_signing, medical_checkup, travel"
81        )),
82    }
83}
84
85fn normalize_enabled_pack_ids(enabled_pack_ids: &[String]) -> Result<Vec<&str>, String> {
86    let mut normalized = Vec::with_capacity(enabled_pack_ids.len());
87    for pack_id in enabled_pack_ids {
88        let trimmed = pack_id.trim();
89        if trimmed.is_empty() {
90            return Err("recommendation pack id must not be empty".to_string());
91        }
92        normalized.push(trimmed);
93    }
94    Ok(normalized)
95}
96
97pub fn get_day_info_for_date(day: i32, month: i32, year: i32) -> Result<DayInfoDto, String> {
98    get_day_info(&DateQuery {
99        day,
100        month,
101        year,
102        timezone: None,
103        ruleset_id: None,
104        event_kind: None,
105        enabled_pack_ids: vec![],
106    })
107}
108
109pub fn get_ruleset_catalog() -> Vec<RulesetCatalogEntryDto> {
110    ruleset_registry()
111        .iter()
112        .map(RulesetCatalogEntryDto::from)
113        .collect()
114}
115
116pub fn get_recommendation_pack_catalog() -> Vec<RecommendationPackCatalogEntryDto> {
117    recommendation_pack_descriptors()
118        .iter()
119        .map(RecommendationPackCatalogEntryDto::from)
120        .collect()
121}
122
123pub fn get_holidays(year: i32, major_only: bool) -> Vec<HolidayDto> {
124    get_vietnamese_holidays(year)
125        .iter()
126        .filter(|h| !major_only || h.is_major)
127        .map(HolidayDto::from)
128        .collect()
129}
130
131pub fn get_day_insight(query: &DateQuery) -> Result<DayInsightDto, String> {
132    get_day_insight_with_profile(query, None, None, None, None)
133}
134
135pub fn get_day_insight_with_profile(
136    query: &DateQuery,
137    birth_year: Option<i32>,
138    birth_month: Option<i32>,
139    birth_day: Option<i32>,
140    gender: Option<amlich_core::almanac::tu_menh::Gender>,
141) -> Result<DayInsightDto, String> {
142    let day_info = get_day_info(query)?;
143
144    let festival = lunar_festivals()
145        .iter()
146        .find(|item| {
147            if item.is_solar {
148                item.solar_day == Some(day_info.solar.day)
149                    && item.solar_month == Some(day_info.solar.month)
150            } else {
151                item.lunar_day == day_info.lunar.day && item.lunar_month == day_info.lunar.month
152            }
153        })
154        .map(FestivalInsightDto::from);
155
156    let holiday = solar_holidays()
157        .iter()
158        .find(|item| {
159            item.solar_day == day_info.solar.day && item.solar_month == day_info.solar.month
160        })
161        .map(HolidayInsightDto::from);
162
163    let can_info = find_can(&day_info.canchi.day.can);
164    let chi_info = find_chi(&day_info.canchi.day.chi);
165    let element_index: &HashMap<String, amlich_core::insight_data::ElementInfo> = all_elements();
166
167    let canchi = match (can_info, chi_info) {
168        (Some(can), Some(chi)) => {
169            let element = element_index
170                .get(&can.element)
171                .map(|el| ElementInsightDto::from((&can.element, el)));
172            Some(CanChiInsightDto {
173                can: CanInsightDto::from(can),
174                chi: ChiInsightDto::from(chi),
175                element,
176            })
177        }
178        _ => None,
179    };
180
181    let day_guidance = get_day_guidance(&day_info.canchi.day.chi).map(DayGuidanceDto::from);
182    let tiet_khi = find_tiet_khi_insight(&day_info.tiet_khi.name).map(TietKhiInsightDto::from);
183
184    let fortune = day_info.day_fortune.as_ref();
185
186    // Na Am insight
187    let na_am = fortune.and_then(|f| {
188        find_na_am_insight(&f.day_element.na_am).map(|n| NaAmInsightDto {
189            na_am: f.day_element.na_am.clone(),
190            element: f.day_element.element.clone(),
191            meaning: LocalizedTextDto::from(&n.meaning),
192        })
193    });
194
195    // Truc insight
196    let truc = fortune.and_then(|f| {
197        find_truc_insight(&f.truc.name).map(|t| TrucInsightDto {
198            name: f.truc.name.clone(),
199            quality: f.truc.quality.clone(),
200            meaning: LocalizedTextDto::from(&t.meaning),
201            good_for: LocalizedListDto::from(&t.good_for),
202            avoid_for: LocalizedListDto::from(&t.avoid_for),
203        })
204    });
205
206    // Day Deity insight
207    let day_deity = fortune.and_then(|f| {
208        f.day_deity.as_ref().map(|deity| {
209            // DTO stores "hoang_dao"/"hac_dao", insight data uses "HoangDao"/"HacDao"
210            let cls_id = match deity.classification.as_str() {
211                "hoang_dao" => "HoangDao",
212                "hac_dao" => "HacDao",
213                other => other,
214            };
215            let cls_meaning = find_deity_classification_insight(cls_id)
216                .map(|c| LocalizedTextDto::from(&c.meaning))
217                .unwrap_or_else(|| LocalizedTextDto {
218                    vi: String::new(),
219                    en: String::new(),
220                });
221            let deity_meaning =
222                find_deity_insight(&deity.name).map(|d| LocalizedTextDto::from(&d.meaning));
223            DayDeityInsightDto {
224                name: deity.name.clone(),
225                classification: deity.classification.clone(),
226                classification_meaning: cls_meaning,
227                deity_meaning,
228            }
229        })
230    });
231
232    // Stars insight
233    let stars = fortune.map(|f| StarsInsightDto {
234        cat_tinh: f.stars.cat_tinh.clone(),
235        sat_tinh: f.stars.sat_tinh.clone(),
236        day_star: f.stars.day_star.as_ref().map(|s| s.name.clone()),
237        day_star_quality: f.stars.day_star.as_ref().map(|s| s.quality.clone()),
238    });
239
240    // Taboos insight
241    let taboos = fortune
242        .map(|f| {
243            f.taboos
244                .iter()
245                .map(|t| TabooInsightItemDto {
246                    name: t.name.clone(),
247                    severity: t.severity.clone(),
248                    reason: t.reason.clone(),
249                })
250                .collect::<Vec<_>>()
251        })
252        .filter(|v| !v.is_empty());
253
254    // Travel insight
255    let travel = fortune.map(|f| TravelInsightDto {
256        xuat_hanh_huong: f.travel.xuat_hanh_huong.clone(),
257        tai_than: f.travel.tai_than.clone(),
258        hy_than: f.travel.hy_than.clone(),
259    });
260
261    // Xung Hop insight
262    let xung_hop = fortune.map(|f| XungHopInsightDto {
263        luc_xung: f.xung_hop.luc_xung.clone(),
264        tam_hop: f.xung_hop.tam_hop.clone(),
265        liu_he: f.xung_hop.liu_he.clone(),
266        xiang_hai: f.xung_hop.xiang_hai.clone(),
267    });
268
269    // Tang Can insight
270    let tang_can = fortune.and_then(|f| {
271        f.tang_can.as_ref().map(|tc| TangCanInsightDto {
272            main: tc.main.clone(),
273            central: tc.central.clone(),
274            residual: tc.residual.clone(),
275            strength: tc.strength,
276        })
277    });
278
279    // Ten Gods insight
280    // DTO labels are snake_case (e.g. "ty_kien"), insight data IDs are PascalCase (e.g. "TyKien")
281    let ten_gods = fortune.and_then(|f| {
282        f.ten_gods.as_ref().map(|tg| {
283            let map_entry = |r: &ThapThanResultDto| -> TenGodsEntryInsightDto {
284                let pascal_id = snake_to_pascal(&r.label);
285                let insight = find_ten_gods_insight(&pascal_id);
286                TenGodsEntryInsightDto {
287                    label: r.label.clone(),
288                    name: insight
289                        .map(|i| LocalizedTextDto::from(&i.name))
290                        .unwrap_or_else(|| LocalizedTextDto {
291                            vi: String::new(),
292                            en: String::new(),
293                        }),
294                    meaning: insight
295                        .map(|i| LocalizedTextDto::from(&i.meaning))
296                        .unwrap_or_else(|| LocalizedTextDto {
297                            vi: String::new(),
298                            en: String::new(),
299                        }),
300                    relation: r.relation.clone(),
301                    same_polarity: r.same_polarity,
302                }
303            };
304            TenGodsInsightDto {
305                to_year_stem: tg.to_year_stem.as_ref().map(map_entry),
306                to_self: tg.to_self.as_ref().map(map_entry),
307            }
308        })
309    });
310
311    // Hours insight
312    let hours = Some(HoursInsightDto {
313        good_hour_count: day_info.gio_hoang_dao.good_hours.len(),
314        good_hours: day_info
315            .gio_hoang_dao
316            .good_hours
317            .iter()
318            .map(|h| HourInsightEntryDto {
319                chi: h.hour_chi.clone(),
320                time_range: h.time_range.clone(),
321                star: h.star.clone(),
322            })
323            .collect(),
324    });
325
326    // Birth-dependent: Tu Menh (Kua)
327    let tu_menh = match (birth_year, gender) {
328        (Some(by), Some(g)) => {
329            use amlich_core::almanac::tu_menh::compute_kua;
330            use amlich_core::insight_data::{find_kua_group_insight, find_kua_insight};
331            let kua_result = compute_kua(by, g);
332            let group_id = format!("{:?}", kua_result.group);
333            let kua_insight = find_kua_insight(kua_result.kua);
334            let group_insight = find_kua_group_insight(&group_id);
335            let empty_text = || LocalizedTextDto {
336                vi: String::new(),
337                en: String::new(),
338            };
339            Some(TuMenhInsightDto {
340                kua: kua_result.kua,
341                group: group_id,
342                trigram: kua_insight
343                    .map(|k| LocalizedTextDto::from(&k.trigram))
344                    .unwrap_or_else(empty_text),
345                direction: kua_insight
346                    .map(|k| LocalizedTextDto::from(&k.direction))
347                    .unwrap_or_else(empty_text),
348                meaning: kua_insight
349                    .map(|k| LocalizedTextDto::from(&k.meaning))
350                    .unwrap_or_else(empty_text),
351                group_meaning: group_insight
352                    .map(|g| LocalizedTextDto::from(&g.meaning))
353                    .unwrap_or_else(empty_text),
354                favorable_directions: kua_result
355                    .favorable_directions
356                    .iter()
357                    .map(|d| format!("{:?}", d))
358                    .collect(),
359                unfavorable_directions: kua_result
360                    .unfavorable_directions
361                    .iter()
362                    .map(|d| format!("{:?}", d))
363                    .collect(),
364            })
365        }
366        _ => None,
367    };
368
369    // Birth-dependent: Dai Van
370    let dai_van = match (birth_day, birth_month, birth_year, gender) {
371        (Some(bd), Some(bm), Some(by), Some(g)) => {
372            use amlich_core::almanac::dai_van::calculate_dai_van;
373            use amlich_core::insight_data::{
374                dai_van_phases_insight, find_dai_van_direction_insight,
375                find_dai_van_element_insight,
376            };
377            let dv = calculate_dai_van(bd, bm, by, g);
378            let dir_id = format!("{:?}", dv.chieu_thu);
379            let dir_insight = find_dai_van_direction_insight(&dir_id);
380            let phases = dai_van_phases_insight();
381            let empty_text = || LocalizedTextDto {
382                vi: String::new(),
383                en: String::new(),
384            };
385            let pillars: Vec<DaiVanPillarInsightDto> = dv
386                .pillars
387                .iter()
388                .map(|p| {
389                    let element = amlich_core::almanac::na_am::get_na_am_by_pair(
390                        &p.can_chi.can,
391                        &p.can_chi.chi,
392                    )
393                    .map(|e| e.element)
394                    .unwrap_or_default();
395                    let el_insight = find_dai_van_element_insight(&element);
396                    DaiVanPillarInsightDto {
397                        index: p.index,
398                        can_chi: p.can_chi.full.clone(),
399                        start_age: p.start_age,
400                        end_age: p.end_age,
401                        element,
402                        element_meaning: el_insight
403                            .map(|e| LocalizedTextDto::from(&e.meaning))
404                            .unwrap_or_else(empty_text),
405                    }
406                })
407                .collect();
408            Some(DaiVanInsightDto {
409                direction: dv.chieu_thu_label.clone(),
410                direction_meaning: dir_insight
411                    .map(|d| LocalizedTextDto::from(&d.meaning))
412                    .unwrap_or_else(empty_text),
413                start_age: dv.start_age_display.clone(),
414                current_pillar: None,
415                all_pillars: pillars,
416                phases_meaning: LocalizedTextDto::from(&phases.meaning),
417            })
418        }
419        _ => None,
420    };
421
422    Ok(DayInsightDto {
423        solar: day_info.solar,
424        lunar: day_info.lunar,
425        festival,
426        holiday,
427        canchi,
428        day_guidance,
429        tiet_khi,
430        na_am,
431        truc,
432        day_deity,
433        stars,
434        taboos,
435        travel,
436        xung_hop,
437        tang_can,
438        ten_gods,
439        hours,
440        tu_menh,
441        dai_van,
442    })
443}
444
445pub fn get_day_insight_for_date(day: i32, month: i32, year: i32) -> Result<DayInsightDto, String> {
446    get_day_insight(&DateQuery {
447        day,
448        month,
449        year,
450        timezone: None,
451        ruleset_id: None,
452        event_kind: None,
453        enabled_pack_ids: vec![],
454    })
455}
456
457pub fn get_day_insight_for_date_with_profile(
458    day: i32,
459    month: i32,
460    year: i32,
461    birth_year: Option<i32>,
462    birth_month: Option<i32>,
463    birth_day: Option<i32>,
464    gender: Option<amlich_core::almanac::tu_menh::Gender>,
465) -> Result<DayInsightDto, String> {
466    get_day_insight_with_profile(
467        &DateQuery {
468            day,
469            month,
470            year,
471            timezone: None,
472            ruleset_id: None,
473            event_kind: None,
474            enabled_pack_ids: vec![],
475        },
476        birth_year,
477        birth_month,
478        birth_day,
479        gender,
480    )
481}
482
483/// Lookup Na Am by 1-based cycle index (1-60)
484///
485/// # Arguments
486/// * `index` - 1-based cycle index in range [1, 60]
487///
488/// # Returns
489/// * `NaAmResponseDto::Success` with Na Am details if index is valid
490/// * `NaAmResponseDto::Error` with error details if index is invalid
491///
492/// # Examples
493/// ```ignore
494/// let response = get_na_am_by_index(1);
495/// match response {
496///     NaAmResponseDto::Success(result) => {
497///         println!("Na Am: {}", result.na_am); // "Hải Trung Kim"
498///     }
499///     NaAmResponseDto::Error(err) => {
500///         eprintln!("Error: {}", err.message);
501///     }
502/// }
503/// ```
504pub fn get_na_am_by_index(index: u8) -> NaAmResponseDto {
505    use amlich_core::almanac::na_am::get_na_am_by_index;
506
507    match get_na_am_by_index(index) {
508        Ok(entry) => NaAmResponseDto::Success(NaAmLookupResultDto::from(&entry)),
509        Err(error) => NaAmResponseDto::Error(NaAmErrorDto::from(error)),
510    }
511}
512
513/// Lookup Na Am by stem-branch pair (Vietnamese names)
514///
515/// # Arguments
516/// * `can` - Vietnamese stem name (e.g., "Giáp", "Ất")
517/// * `chi` - Vietnamese branch name (e.g., "Tý", "Sửu")
518///
519/// # Returns
520/// * `NaAmResponseDto::Success` with Na Am details if pair is valid
521/// * `NaAmResponseDto::Error` with error details if pair is invalid
522///
523/// # Examples
524///
525/// Lookup by cycle index (1-60):
526///
527/// ```ignore
528/// use amlich_api::{get_na_am_by_index, get_na_am_by_pair};
529///
530/// let result = get_na_am_by_index(1);
531/// match result {
532///     NaAmResponseDto::Success(data) => {
533///         println!("Index {}: {} {} - {} ({})",
534///             data.cycle_index, data.can, data.chi, data.na_am, data.element);
535///     }
536///     NaAmResponseDto::Error(err) => {
537///         eprintln!("Error: {}", err.message);
538///     }
539/// }
540/// ```
541///
542/// Lookup by stem-branch pair:
543///
544/// ```ignore
545/// let result = get_na_am_by_pair("Giáp", "Tý");
546/// match result {
547///     NaAmResponseDto::Success(data) => {
548///         println!("{} {}: {} ({})",
549///             data.can, data.chi, data.na_am, data.element);
550///     }
551///     NaAmResponseDto::Error(err) => {
552///         eprintln!("Error: {}", err.message);
553///     }
554/// }
555/// ```
556pub fn get_na_am_by_pair(can: &str, chi: &str) -> NaAmResponseDto {
557    use amlich_core::almanac::na_am::get_na_am_by_pair;
558
559    match get_na_am_by_pair(can, chi) {
560        Ok(entry) => NaAmResponseDto::Success(NaAmLookupResultDto::from(&entry)),
561        Err(error) => NaAmResponseDto::Error(NaAmErrorDto::from(error)),
562    }
563}