Skip to main content

amlich_core/almanac/
data.rs

1use std::collections::{HashMap, HashSet};
2use std::error::Error;
3use std::fmt;
4use std::sync::OnceLock;
5
6use serde::Deserialize;
7
8use super::types::{
9    RuleSetDefaults, RuleSetDescriptor as RulesetDescriptorDoc, RuleSetSourceNote, SourceMeta,
10};
11use crate::tietkhi::TIET_KHI;
12use crate::types::{CAN, CHI};
13
14const BASELINE_JSON: &str = include_str!("../../data/almanac/baseline.json");
15pub const DEFAULT_RULESET_ID: &str = "vn_baseline_v1";
16const BASELINE_RULESET_ALIAS: &str = "baseline";
17const DEFAULT_RULESET_VERSION: &str = "v1";
18const DEFAULT_RULESET_REGION: &str = "vn";
19const DEFAULT_RULESET_SCHEMA_VERSION: &str = "ruleset-descriptor/v1";
20const DEFAULT_RULESET_TZ_OFFSET: f64 = 7.0;
21const VALID_REGIONS: [&str; 1] = ["vn"];
22const VALID_DIRECTIONS: [&str; 8] = [
23    "Bắc",
24    "Đông Bắc",
25    "Đông",
26    "Đông Nam",
27    "Nam",
28    "Tây Nam",
29    "Tây",
30    "Tây Bắc",
31];
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct TravelRule {
35    pub xuat_hanh_huong: String,
36    pub tai_than: String,
37    pub hy_than: String,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41pub struct ConflictRule {
42    pub opposing_chi: String,
43    pub sat_huong: String,
44    pub cat_tinh: Vec<String>,
45    pub sat_tinh: Vec<String>,
46}
47
48#[derive(Debug, Clone)]
49pub struct NaAmEntry {
50    pub can: String,
51    pub chi: String,
52    pub na_am: String,
53    pub element: String,
54}
55
56#[derive(Debug, Clone, Deserialize)]
57pub struct DayStarRule {
58    pub name: String,
59    pub quality: String,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63pub struct StarRuleBucketRaw {
64    pub cat_tinh: Vec<String>,
65    pub sat_tinh: Vec<String>,
66    #[serde(default)]
67    pub binh_tinh: Vec<String>,
68}
69
70#[derive(Debug, Clone)]
71pub struct StarRuleBucket {
72    pub cat_tinh: Vec<String>,
73    pub sat_tinh: Vec<String>,
74    pub binh_tinh: Vec<String>,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78pub struct StarRuleMetaSet {
79    pub fixed_by_chi: SourceMeta,
80    pub fixed_by_canchi: SourceMeta,
81    pub by_year: SourceMeta,
82    pub by_month: SourceMeta,
83    pub by_tiet_khi: SourceMeta,
84}
85
86#[derive(Debug, Clone, Deserialize)]
87pub struct DayDeityMeta {
88    pub source_id: String,
89    pub method: String,
90}
91
92#[derive(Debug, Clone, Deserialize)]
93pub struct DayDeityRuleRaw {
94    pub name: String,
95    pub classification: String,
96}
97
98#[derive(Debug, Clone)]
99pub struct DayDeityRule {
100    pub name: String,
101    pub classification: String,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct DayDeityRuleSetRaw {
106    pub cycle: Vec<DayDeityRuleRaw>,
107    pub month_group_start_by_chi: HashMap<String, usize>,
108}
109
110#[derive(Debug, Clone)]
111pub struct DayDeityRuleSet {
112    pub cycle: Vec<DayDeityRule>,
113    pub month_group_start_by_chi: HashMap<String, usize>,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct TabooRuleMetaSet {
118    pub tam_nuong: SourceMeta,
119    pub nguyet_ky: SourceMeta,
120    pub sat_chu: SourceMeta,
121    pub tho_tu: SourceMeta,
122}
123
124#[derive(Debug, Clone, Deserialize)]
125pub struct TabooDayRuleRaw {
126    pub rule_id: String,
127    pub name: String,
128    pub severity: String,
129    pub lunar_days: Vec<u8>,
130}
131
132#[derive(Debug, Clone)]
133pub struct TabooDayRule {
134    pub rule_id: String,
135    pub name: String,
136    pub severity: String,
137    pub lunar_days: Vec<u8>,
138}
139
140#[derive(Debug, Clone, Deserialize)]
141pub struct TabooMonthChiRuleRaw {
142    pub rule_id: String,
143    pub name: String,
144    pub severity: String,
145    pub by_lunar_month: HashMap<String, String>,
146}
147
148#[derive(Debug, Clone)]
149pub struct TabooMonthChiRule {
150    pub rule_id: String,
151    pub name: String,
152    pub severity: String,
153    pub by_lunar_month: HashMap<u8, String>,
154}
155
156#[derive(Debug, Clone, Deserialize)]
157pub struct TabooRuleSetsRaw {
158    pub tam_nuong: TabooDayRuleRaw,
159    pub nguyet_ky: TabooDayRuleRaw,
160    pub sat_chu: TabooMonthChiRuleRaw,
161    pub tho_tu: TabooMonthChiRuleRaw,
162}
163
164#[derive(Debug, Clone)]
165pub struct TabooRuleSets {
166    pub tam_nuong: TabooDayRule,
167    pub nguyet_ky: TabooDayRule,
168    pub sat_chu: TabooMonthChiRule,
169    pub tho_tu: TabooMonthChiRule,
170}
171
172#[derive(Debug, Clone, Deserialize)]
173pub struct StarRuleSetsRaw {
174    pub fixed_by_canchi: HashMap<String, StarRuleBucketRaw>,
175    pub by_year_can: HashMap<String, StarRuleBucketRaw>,
176    pub by_lunar_month: HashMap<String, StarRuleBucketRaw>,
177    pub by_tiet_khi: HashMap<String, StarRuleBucketRaw>,
178}
179
180#[derive(Debug, Clone)]
181pub struct AlmanacData {
182    pub profile: String,
183    pub travel_meta: SourceMeta,
184    pub conflict_meta: SourceMeta,
185    pub na_am_meta: SourceMeta,
186    pub star_meta: SourceMeta,
187    pub day_deity_meta: SourceMeta,
188    pub taboo_rule_meta: TabooRuleMetaSet,
189    pub travel_by_can: HashMap<String, TravelRule>,
190    pub conflict_by_chi: HashMap<String, ConflictRule>,
191    pub sexagenary_na_am: HashMap<String, NaAmEntry>,
192    pub nhi_thap_bat_tu: Vec<DayStarRule>,
193    pub star_rule_meta: StarRuleMetaSet,
194    pub star_rules_fixed_by_canchi: HashMap<String, StarRuleBucket>,
195    pub star_rules_by_year_can: HashMap<String, StarRuleBucket>,
196    pub star_rules_by_lunar_month: HashMap<u8, StarRuleBucket>,
197    pub star_rules_by_tiet_khi: HashMap<String, StarRuleBucket>,
198    pub day_deity_rule_set: DayDeityRuleSet,
199    pub taboo_rules: TabooRuleSets,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub struct RulesetDescriptor {
204    pub id: &'static str,
205    pub version: &'static str,
206    pub region: &'static str,
207    pub profile: &'static str,
208}
209
210impl RulesetDescriptor {
211    pub fn to_document_descriptor(self) -> RulesetDescriptorDoc {
212        RulesetDescriptorDoc {
213            id: self.id.to_string(),
214            version: self.version.to_string(),
215            region: self.region.to_string(),
216            profile: self.profile.to_string(),
217            defaults: RuleSetDefaults {
218                tz_offset: DEFAULT_RULESET_TZ_OFFSET,
219                meridian: None,
220            },
221            source_notes: vec![
222                RuleSetSourceNote {
223                    family: "travel".to_string(),
224                    source_id: "khcbppt".to_string(),
225                    note: "Direction table and bai quyet mapping".to_string(),
226                },
227                RuleSetSourceNote {
228                    family: "taboo_rules".to_string(),
229                    source_id: "khcbppt".to_string(),
230                    note: "Tam Nuong/Nguyet Ky/Sat Chu/Tho Tu frozen for v1".to_string(),
231                },
232            ],
233            schema_version: DEFAULT_RULESET_SCHEMA_VERSION.to_string(),
234        }
235    }
236}
237
238#[derive(Debug, Clone, Copy)]
239pub struct RulesetRegistryEntry {
240    pub descriptor: RulesetDescriptor,
241    pub aliases: &'static [&'static str],
242    loader: fn() -> &'static AlmanacData,
243}
244
245impl RulesetRegistryEntry {
246    pub fn data(&self) -> &'static AlmanacData {
247        (self.loader)()
248    }
249
250    fn matches_id(&self, ruleset_id: &str) -> bool {
251        self.descriptor.id == ruleset_id || self.aliases.contains(&ruleset_id)
252    }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum RulesetLookupError {
257    UnknownRulesetId(String),
258}
259
260impl fmt::Display for RulesetLookupError {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        match self {
263            Self::UnknownRulesetId(id) => write!(f, "unknown almanac ruleset id: {id}"),
264        }
265    }
266}
267
268impl Error for RulesetLookupError {}
269
270#[derive(Debug, Clone, Deserialize)]
271struct RawAlmanacData {
272    profile: String,
273    travel_meta: SourceMeta,
274    conflict_meta: SourceMeta,
275    na_am_meta: SourceMeta,
276    star_meta: SourceMeta,
277    day_deity_meta: SourceMeta,
278    taboo_rule_meta: TabooRuleMetaSet,
279    travel_by_can: HashMap<String, TravelRule>,
280    conflict_by_chi: HashMap<String, ConflictRule>,
281    na_am_pairs: Vec<String>,
282    nhi_thap_bat_tu: Vec<DayStarRule>,
283    star_rule_meta: StarRuleMetaSet,
284    star_rule_sets: StarRuleSetsRaw,
285    day_deity_rule_set: DayDeityRuleSetRaw,
286    taboo_rule_sets: TabooRuleSetsRaw,
287}
288
289static BASELINE_DATA: OnceLock<AlmanacData> = OnceLock::new();
290
291static RULESET_REGISTRY: [RulesetRegistryEntry; 1] = [RulesetRegistryEntry {
292    descriptor: RulesetDescriptor {
293        id: DEFAULT_RULESET_ID,
294        version: DEFAULT_RULESET_VERSION,
295        region: DEFAULT_RULESET_REGION,
296        profile: BASELINE_RULESET_ALIAS,
297    },
298    aliases: &[BASELINE_RULESET_ALIAS],
299    loader: baseline_data,
300}];
301
302pub fn default_ruleset() -> &'static RulesetRegistryEntry {
303    &RULESET_REGISTRY[0]
304}
305
306pub fn ruleset_registry() -> &'static [RulesetRegistryEntry] {
307    &RULESET_REGISTRY
308}
309
310pub fn get_ruleset(ruleset_id: &str) -> Result<&'static RulesetRegistryEntry, RulesetLookupError> {
311    RULESET_REGISTRY
312        .iter()
313        .find(|entry| entry.matches_id(ruleset_id))
314        .ok_or_else(|| RulesetLookupError::UnknownRulesetId(ruleset_id.to_string()))
315}
316
317pub fn get_ruleset_data(ruleset_id: &str) -> Result<&'static AlmanacData, RulesetLookupError> {
318    get_ruleset(ruleset_id).map(RulesetRegistryEntry::data)
319}
320
321pub fn get_ruleset_descriptor_doc(
322    ruleset_id: &str,
323) -> Result<RulesetDescriptorDoc, RulesetLookupError> {
324    let descriptor =
325        get_ruleset(ruleset_id).map(|entry| entry.descriptor.to_document_descriptor())?;
326    validate_ruleset_descriptor_doc(&descriptor);
327    Ok(descriptor)
328}
329
330fn validate_ruleset_descriptor_doc(descriptor: &RulesetDescriptorDoc) {
331    assert!(
332        !descriptor.id.trim().is_empty(),
333        "ruleset descriptor id must not be empty"
334    );
335    assert!(
336        !descriptor.version.trim().is_empty(),
337        "ruleset descriptor version must not be empty"
338    );
339    assert!(
340        VALID_REGIONS.contains(&descriptor.region.as_str()),
341        "ruleset descriptor region '{}' is not supported",
342        descriptor.region
343    );
344    assert!(
345        !descriptor.profile.trim().is_empty(),
346        "ruleset descriptor profile must not be empty"
347    );
348    assert!(
349        matches!(descriptor.defaults.tz_offset, -12.0..=14.0),
350        "ruleset descriptor defaults.tz_offset must be in -12..14"
351    );
352    if let Some(meridian) = &descriptor.defaults.meridian {
353        assert!(
354            !meridian.trim().is_empty(),
355            "ruleset descriptor defaults.meridian must not be empty when provided"
356        );
357    }
358    assert_eq!(
359        descriptor.schema_version, DEFAULT_RULESET_SCHEMA_VERSION,
360        "ruleset descriptor schema_version must be '{}'",
361        DEFAULT_RULESET_SCHEMA_VERSION
362    );
363    validate_ruleset_source_notes(&descriptor.source_notes);
364}
365
366fn validate_ruleset_source_notes(notes: &[RuleSetSourceNote]) {
367    let mut seen = HashSet::new();
368    for note in notes {
369        assert!(
370            !note.family.trim().is_empty(),
371            "ruleset descriptor source note family must not be empty"
372        );
373        assert!(
374            !note.source_id.trim().is_empty(),
375            "ruleset descriptor source note source_id must not be empty"
376        );
377        assert!(
378            !note.note.trim().is_empty(),
379            "ruleset descriptor source note note must not be empty"
380        );
381        assert!(
382            seen.insert(note.family.as_str()),
383            "ruleset descriptor source notes contain duplicate family: {}",
384            note.family
385        );
386    }
387}
388
389pub fn baseline_data() -> &'static AlmanacData {
390    BASELINE_DATA.get_or_init(|| {
391        let raw: RawAlmanacData =
392            serde_json::from_str(BASELINE_JSON).expect("Failed to parse baseline almanac data");
393
394        validate_raw_data(&raw);
395
396        AlmanacData {
397            profile: raw.profile,
398            travel_meta: raw.travel_meta,
399            conflict_meta: raw.conflict_meta,
400            na_am_meta: raw.na_am_meta,
401            star_meta: raw.star_meta,
402            day_deity_meta: raw.day_deity_meta,
403            taboo_rule_meta: raw.taboo_rule_meta,
404            travel_by_can: raw.travel_by_can,
405            conflict_by_chi: raw.conflict_by_chi,
406            sexagenary_na_am: expand_sexagenary_na_am(&raw.na_am_pairs),
407            nhi_thap_bat_tu: raw.nhi_thap_bat_tu,
408            star_rule_meta: raw.star_rule_meta,
409            star_rules_fixed_by_canchi: raw
410                .star_rule_sets
411                .fixed_by_canchi
412                .into_iter()
413                .map(|(k, v)| (k, normalize_star_rule_bucket(v)))
414                .collect(),
415            star_rules_by_year_can: raw
416                .star_rule_sets
417                .by_year_can
418                .into_iter()
419                .map(|(k, v)| (k, normalize_star_rule_bucket(v)))
420                .collect(),
421            star_rules_by_lunar_month: parse_lunar_month_rule_map(
422                raw.star_rule_sets.by_lunar_month,
423            ),
424            star_rules_by_tiet_khi: raw
425                .star_rule_sets
426                .by_tiet_khi
427                .into_iter()
428                .map(|(k, v)| (k, normalize_star_rule_bucket(v)))
429                .collect(),
430            day_deity_rule_set: normalize_day_deity_rule_set(raw.day_deity_rule_set),
431            taboo_rules: normalize_taboo_rule_sets(raw.taboo_rule_sets),
432        }
433    })
434}
435
436fn validate_raw_data(raw: &RawAlmanacData) {
437    validate_source_meta(&raw.travel_meta, "travel_meta");
438    validate_source_meta(&raw.conflict_meta, "conflict_meta");
439    validate_source_meta(&raw.na_am_meta, "na_am_meta");
440    validate_source_meta(&raw.star_meta, "star_meta");
441    validate_source_meta(&raw.day_deity_meta, "day_deity_meta");
442    validate_taboo_rule_meta(&raw.taboo_rule_meta);
443    validate_can_map(&raw.travel_by_can);
444    validate_chi_map(&raw.conflict_by_chi);
445    validate_directions(raw);
446    validate_conflict_stars(&raw.conflict_by_chi);
447    validate_na_am_pairs(&raw.na_am_pairs);
448    validate_nhi_thap_bat_tu(&raw.nhi_thap_bat_tu);
449    validate_star_rule_meta(&raw.star_rule_meta);
450    validate_star_rule_sets(&raw.star_rule_sets);
451    validate_day_deity_rule_set(&raw.day_deity_rule_set);
452    validate_taboo_rule_sets(&raw.taboo_rule_sets);
453}
454
455const VALID_METHODS: [&str; 3] = ["table-lookup", "bai-quyet", "jd-cycle"];
456
457pub fn is_valid_method(method: &str) -> bool {
458    VALID_METHODS.contains(&method)
459}
460
461fn validate_source_meta(meta: &SourceMeta, field: &str) {
462    assert!(
463        !meta.source_id.is_empty(),
464        "{field}.source_id must not be empty"
465    );
466    assert!(
467        is_valid_method(&meta.method),
468        "{field}.method '{}' is not a valid method token",
469        meta.method
470    );
471}
472
473fn validate_star_rule_meta(meta: &StarRuleMetaSet) {
474    validate_source_meta(&meta.fixed_by_chi, "star_rule_meta.fixed_by_chi");
475    validate_source_meta(&meta.fixed_by_canchi, "star_rule_meta.fixed_by_canchi");
476    validate_source_meta(&meta.by_year, "star_rule_meta.by_year");
477    validate_source_meta(&meta.by_month, "star_rule_meta.by_month");
478    validate_source_meta(&meta.by_tiet_khi, "star_rule_meta.by_tiet_khi");
479}
480
481fn validate_taboo_rule_meta(meta: &TabooRuleMetaSet) {
482    validate_source_meta(&meta.tam_nuong, "taboo_rule_meta.tam_nuong");
483    validate_source_meta(&meta.nguyet_ky, "taboo_rule_meta.nguyet_ky");
484    validate_source_meta(&meta.sat_chu, "taboo_rule_meta.sat_chu");
485    validate_source_meta(&meta.tho_tu, "taboo_rule_meta.tho_tu");
486}
487
488fn validate_can_map(map: &HashMap<String, TravelRule>) {
489    let expected: HashSet<&str> = CAN.iter().copied().collect();
490    let actual: HashSet<&str> = map.keys().map(String::as_str).collect();
491    assert_eq!(
492        actual, expected,
493        "travel_by_can must contain exactly 10 can"
494    );
495}
496
497fn validate_chi_map(map: &HashMap<String, ConflictRule>) {
498    let expected: HashSet<&str> = CHI.iter().copied().collect();
499    let actual: HashSet<&str> = map.keys().map(String::as_str).collect();
500    assert_eq!(
501        actual, expected,
502        "conflict_by_chi must contain exactly 12 chi"
503    );
504}
505
506fn validate_directions(raw: &RawAlmanacData) {
507    for rule in raw.travel_by_can.values() {
508        assert!(
509            is_valid_direction(&rule.xuat_hanh_huong),
510            "invalid direction for xuat_hanh_huong: {}",
511            rule.xuat_hanh_huong
512        );
513        assert!(
514            is_valid_direction(&rule.tai_than),
515            "invalid direction for tai_than: {}",
516            rule.tai_than
517        );
518        assert!(
519            is_valid_direction(&rule.hy_than),
520            "invalid direction for hy_than: {}",
521            rule.hy_than
522        );
523    }
524
525    for rule in raw.conflict_by_chi.values() {
526        assert!(
527            is_valid_direction(&rule.sat_huong),
528            "invalid direction for sat_huong: {}",
529            rule.sat_huong
530        );
531    }
532}
533
534fn validate_conflict_stars(map: &HashMap<String, ConflictRule>) {
535    for (chi, rule) in map {
536        assert!(
537            !rule.cat_tinh.is_empty(),
538            "conflict_by_chi[{chi}].cat_tinh must not be empty"
539        );
540        assert!(
541            !rule.sat_tinh.is_empty(),
542            "conflict_by_chi[{chi}].sat_tinh must not be empty"
543        );
544    }
545}
546
547fn validate_na_am_pairs(values: &[String]) {
548    assert_eq!(
549        values.len(),
550        30,
551        "na_am_pairs must contain exactly 30 items"
552    );
553    for value in values {
554        assert!(!value.trim().is_empty(), "na_am_pairs cannot contain empty");
555    }
556}
557
558fn validate_nhi_thap_bat_tu(values: &[DayStarRule]) {
559    assert_eq!(
560        values.len(),
561        28,
562        "nhi_thap_bat_tu must contain exactly 28 stars"
563    );
564    for star in values {
565        assert!(!star.name.trim().is_empty(), "star name cannot be empty");
566        assert!(
567            matches!(star.quality.as_str(), "cat" | "hung" | "binh"),
568            "invalid star quality: {}",
569            star.quality
570        );
571    }
572}
573
574fn normalize_star_rule_bucket(raw: StarRuleBucketRaw) -> StarRuleBucket {
575    StarRuleBucket {
576        cat_tinh: raw.cat_tinh,
577        sat_tinh: raw.sat_tinh,
578        binh_tinh: raw.binh_tinh,
579    }
580}
581
582fn parse_lunar_month_rule_map(
583    raw: HashMap<String, StarRuleBucketRaw>,
584) -> HashMap<u8, StarRuleBucket> {
585    raw.into_iter()
586        .map(|(month, bucket)| {
587            let value = month
588                .parse::<u8>()
589                .expect("star_rule_sets.by_lunar_month key must be a numeric month string");
590            (value, normalize_star_rule_bucket(bucket))
591        })
592        .collect()
593}
594
595fn normalize_day_deity_rule_set(raw: DayDeityRuleSetRaw) -> DayDeityRuleSet {
596    DayDeityRuleSet {
597        cycle: raw
598            .cycle
599            .into_iter()
600            .map(|entry| DayDeityRule {
601                name: entry.name,
602                classification: entry.classification,
603            })
604            .collect(),
605        month_group_start_by_chi: raw.month_group_start_by_chi,
606    }
607}
608
609fn normalize_taboo_rule_sets(raw: TabooRuleSetsRaw) -> TabooRuleSets {
610    TabooRuleSets {
611        tam_nuong: TabooDayRule {
612            rule_id: raw.tam_nuong.rule_id,
613            name: raw.tam_nuong.name,
614            severity: raw.tam_nuong.severity,
615            lunar_days: raw.tam_nuong.lunar_days,
616        },
617        nguyet_ky: TabooDayRule {
618            rule_id: raw.nguyet_ky.rule_id,
619            name: raw.nguyet_ky.name,
620            severity: raw.nguyet_ky.severity,
621            lunar_days: raw.nguyet_ky.lunar_days,
622        },
623        sat_chu: TabooMonthChiRule {
624            rule_id: raw.sat_chu.rule_id,
625            name: raw.sat_chu.name,
626            severity: raw.sat_chu.severity,
627            by_lunar_month: parse_taboo_month_chi_map(raw.sat_chu.by_lunar_month),
628        },
629        tho_tu: TabooMonthChiRule {
630            rule_id: raw.tho_tu.rule_id,
631            name: raw.tho_tu.name,
632            severity: raw.tho_tu.severity,
633            by_lunar_month: parse_taboo_month_chi_map(raw.tho_tu.by_lunar_month),
634        },
635    }
636}
637
638fn parse_taboo_month_chi_map(raw: HashMap<String, String>) -> HashMap<u8, String> {
639    raw.into_iter()
640        .map(|(month, chi)| {
641            let value = month
642                .parse::<u8>()
643                .expect("taboo by_lunar_month key must be a numeric month string");
644            (value, chi)
645        })
646        .collect()
647}
648
649fn validate_star_rule_sets(sets: &StarRuleSetsRaw) {
650    validate_fixed_by_canchi_map(&sets.fixed_by_canchi);
651    validate_by_year_can_map(&sets.by_year_can);
652    validate_by_lunar_month_map(&sets.by_lunar_month);
653    validate_by_tiet_khi_map(&sets.by_tiet_khi);
654}
655
656fn validate_day_deity_rule_set(rule_set: &DayDeityRuleSetRaw) {
657    assert_eq!(
658        rule_set.cycle.len(),
659        12,
660        "day_deity_rule_set.cycle must contain exactly 12 entries"
661    );
662
663    for (idx, entry) in rule_set.cycle.iter().enumerate() {
664        assert!(
665            !entry.name.trim().is_empty(),
666            "day_deity_rule_set.cycle[{idx}].name must not be empty"
667        );
668        assert!(
669            matches!(entry.classification.as_str(), "hoang_dao" | "hac_dao"),
670            "day_deity_rule_set.cycle[{idx}].classification must be hoang_dao|hac_dao"
671        );
672    }
673
674    let expected: HashSet<&str> = CHI.iter().copied().collect();
675    let actual: HashSet<&str> = rule_set
676        .month_group_start_by_chi
677        .keys()
678        .map(String::as_str)
679        .collect();
680    assert_eq!(
681        actual, expected,
682        "day_deity_rule_set.month_group_start_by_chi must contain all 12 chi keys"
683    );
684
685    for (chi, start) in &rule_set.month_group_start_by_chi {
686        assert!(
687            *start < 12,
688            "day_deity_rule_set.month_group_start_by_chi[{chi}] must be in 0..12"
689        );
690    }
691}
692
693fn validate_taboo_rule_sets(sets: &TabooRuleSetsRaw) {
694    validate_taboo_day_rule(&sets.tam_nuong, "taboo_rule_sets.tam_nuong", "tam_nuong");
695    validate_taboo_day_rule(&sets.nguyet_ky, "taboo_rule_sets.nguyet_ky", "nguyet_ky");
696    validate_taboo_month_chi_rule(&sets.sat_chu, "taboo_rule_sets.sat_chu", "sat_chu");
697    validate_taboo_month_chi_rule(&sets.tho_tu, "taboo_rule_sets.tho_tu", "tho_tu");
698}
699
700fn validate_taboo_day_rule(rule: &TabooDayRuleRaw, path: &str, expected_rule_id: &str) {
701    validate_taboo_common_fields(
702        &rule.rule_id,
703        &rule.name,
704        &rule.severity,
705        path,
706        expected_rule_id,
707    );
708    assert!(
709        !rule.lunar_days.is_empty(),
710        "{path}.lunar_days must not be empty"
711    );
712
713    let mut seen = HashSet::new();
714    for day in &rule.lunar_days {
715        assert!(
716            (1..=30).contains(day),
717            "{path}.lunar_days contains out-of-range lunar day: {day}"
718        );
719        assert!(
720            seen.insert(*day),
721            "{path}.lunar_days contains duplicate lunar day: {day}"
722        );
723    }
724}
725
726fn validate_taboo_month_chi_rule(rule: &TabooMonthChiRuleRaw, path: &str, expected_rule_id: &str) {
727    validate_taboo_common_fields(
728        &rule.rule_id,
729        &rule.name,
730        &rule.severity,
731        path,
732        expected_rule_id,
733    );
734    assert_eq!(
735        rule.by_lunar_month.len(),
736        12,
737        "{path}.by_lunar_month must contain exactly 12 months"
738    );
739
740    let expected_months: HashSet<u8> = (1..=12).collect();
741    let mut actual_months = HashSet::new();
742
743    for (month, chi) in &rule.by_lunar_month {
744        let month_num = month
745            .parse::<u8>()
746            .expect("taboo by_lunar_month key must be a numeric month string");
747        assert!(
748            (1..=12).contains(&month_num),
749            "{path}.by_lunar_month key out of range 1..12: {month}"
750        );
751        actual_months.insert(month_num);
752        assert!(
753            CHI.contains(&chi.as_str()),
754            "{path}.by_lunar_month[{month}] contains invalid chi: {chi}"
755        );
756    }
757
758    assert_eq!(
759        actual_months, expected_months,
760        "{path}.by_lunar_month must define all lunar months 1..12"
761    );
762}
763
764fn validate_taboo_common_fields(
765    rule_id: &str,
766    name: &str,
767    severity: &str,
768    path: &str,
769    expected_rule_id: &str,
770) {
771    assert_eq!(
772        rule_id, expected_rule_id,
773        "{path}.rule_id must be '{expected_rule_id}'"
774    );
775    assert!(!name.trim().is_empty(), "{path}.name must not be empty");
776    assert!(
777        is_valid_taboo_severity(severity),
778        "{path}.severity must be one of 'hard' | 'soft' (got '{severity}')"
779    );
780}
781
782fn validate_fixed_by_canchi_map(map: &HashMap<String, StarRuleBucketRaw>) {
783    for (key, bucket) in map {
784        assert!(
785            is_valid_sexagenary_key(key),
786            "star_rule_sets.fixed_by_canchi contains invalid canchi key: {key}"
787        );
788        validate_star_rule_bucket(bucket, &format!("star_rule_sets.fixed_by_canchi[{key}]"));
789    }
790}
791
792fn validate_by_year_can_map(map: &HashMap<String, StarRuleBucketRaw>) {
793    for (key, bucket) in map {
794        assert!(
795            CAN.contains(&key.as_str()),
796            "star_rule_sets.by_year_can contains invalid can key: {key}"
797        );
798        validate_star_rule_bucket(bucket, &format!("star_rule_sets.by_year_can[{key}]"));
799    }
800}
801
802fn validate_by_lunar_month_map(map: &HashMap<String, StarRuleBucketRaw>) {
803    for (key, bucket) in map {
804        let month = key
805            .parse::<u8>()
806            .expect("star_rule_sets.by_lunar_month key must be numeric");
807        assert!(
808            (1..=12).contains(&month),
809            "star_rule_sets.by_lunar_month key out of range 1..12: {key}"
810        );
811        validate_star_rule_bucket(bucket, &format!("star_rule_sets.by_lunar_month[{key}]"));
812    }
813}
814
815fn validate_by_tiet_khi_map(map: &HashMap<String, StarRuleBucketRaw>) {
816    for (key, bucket) in map {
817        assert!(
818            is_valid_tiet_khi_name(key),
819            "star_rule_sets.by_tiet_khi contains unknown tiet khi key: {key}"
820        );
821        validate_star_rule_bucket(bucket, &format!("star_rule_sets.by_tiet_khi[{key}]"));
822    }
823}
824
825fn validate_star_rule_bucket(bucket: &StarRuleBucketRaw, path: &str) {
826    validate_nonempty_star_names(&bucket.cat_tinh, &format!("{path}.cat_tinh"));
827    validate_nonempty_star_names(&bucket.sat_tinh, &format!("{path}.sat_tinh"));
828    validate_nonempty_star_names(&bucket.binh_tinh, &format!("{path}.binh_tinh"));
829
830    let mut seen = HashSet::new();
831    for star in &bucket.cat_tinh {
832        assert!(
833            seen.insert(star),
834            "{path}: duplicate star across categories: {star}"
835        );
836    }
837    for star in &bucket.sat_tinh {
838        assert!(
839            seen.insert(star),
840            "{path}: duplicate star across categories: {star}"
841        );
842    }
843    for star in &bucket.binh_tinh {
844        assert!(
845            seen.insert(star),
846            "{path}: duplicate star across categories: {star}"
847        );
848    }
849}
850
851fn validate_nonempty_star_names(stars: &[String], path: &str) {
852    for star in stars {
853        assert!(
854            !star.trim().is_empty(),
855            "{path} must not contain empty star names"
856        );
857    }
858}
859
860fn is_valid_taboo_severity(value: &str) -> bool {
861    matches!(value, "hard" | "soft")
862}
863
864fn is_valid_tiet_khi_name(name: &str) -> bool {
865    TIET_KHI.iter().any(|term| term.name == name)
866}
867
868fn is_valid_sexagenary_key(key: &str) -> bool {
869    (0..60).any(|i| key == format!("{} {}", CAN[i % 10], CHI[i % 12]))
870}
871
872pub fn is_valid_direction(direction: &str) -> bool {
873    VALID_DIRECTIONS.contains(&direction)
874}
875
876fn expand_sexagenary_na_am(na_am_pairs: &[String]) -> HashMap<String, NaAmEntry> {
877    let mut out = HashMap::with_capacity(60);
878    for i in 0..60 {
879        let can = CAN[i % 10];
880        let chi = CHI[i % 12];
881        let na_am = na_am_pairs[i / 2].clone();
882        let element = na_am.split_whitespace().last().unwrap_or("").to_string();
883        out.insert(
884            format!("{can} {chi}"),
885            NaAmEntry {
886                can: can.to_string(),
887                chi: chi.to_string(),
888                na_am,
889                element,
890            },
891        );
892    }
893    out
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    #[test]
901    fn rejects_invalid_method_tokens() {
902        assert!(
903            !is_valid_method("BAD_METHOD"),
904            "unknown token must be rejected"
905        );
906        assert!(
907            !is_valid_method("NORTH"),
908            "English direction must be rejected"
909        );
910        assert!(!is_valid_method(""), "empty string must be rejected");
911        assert!(is_valid_method("table-lookup"));
912        assert!(is_valid_method("bai-quyet"));
913        assert!(is_valid_method("jd-cycle"));
914    }
915
916    #[test]
917    fn rejects_invalid_direction_tokens() {
918        assert!(
919            !is_valid_direction("NORTH"),
920            "English direction must be rejected"
921        );
922        assert!(!is_valid_direction("North"), "mixed-case must be rejected");
923        assert!(!is_valid_direction(""), "empty string must be rejected");
924        assert!(is_valid_direction("Bắc"));
925        assert!(is_valid_direction("Đông Bắc"));
926        assert!(is_valid_direction("Tây Nam"));
927    }
928
929    #[test]
930    fn source_metadata_fields_exist() {
931        let data = baseline_data();
932        assert!(
933            !data.travel_meta.source_id.is_empty(),
934            "travel_meta.source_id must not be empty"
935        );
936        assert!(
937            !data.travel_meta.method.is_empty(),
938            "travel_meta.method must not be empty"
939        );
940        assert!(
941            !data.conflict_meta.source_id.is_empty(),
942            "conflict_meta.source_id must not be empty"
943        );
944        assert!(
945            !data.conflict_meta.method.is_empty(),
946            "conflict_meta.method must not be empty"
947        );
948        assert!(
949            !data.na_am_meta.source_id.is_empty(),
950            "na_am_meta.source_id must not be empty"
951        );
952        assert!(
953            !data.na_am_meta.method.is_empty(),
954            "na_am_meta.method must not be empty"
955        );
956        assert!(
957            !data.star_meta.source_id.is_empty(),
958            "star_meta.source_id must not be empty"
959        );
960        assert!(
961            !data.star_meta.method.is_empty(),
962            "star_meta.method must not be empty"
963        );
964    }
965
966    #[test]
967    fn validates_expected_collection_sizes() {
968        let data = baseline_data();
969        assert_eq!(data.travel_by_can.len(), 10);
970        assert_eq!(data.conflict_by_chi.len(), 12);
971        assert_eq!(data.sexagenary_na_am.len(), 60);
972    }
973
974    #[test]
975    fn validates_direction_tokens() {
976        let data = baseline_data();
977        for entry in data.travel_by_can.values() {
978            assert!(is_valid_direction(&entry.xuat_hanh_huong));
979            assert!(is_valid_direction(&entry.tai_than));
980            assert!(is_valid_direction(&entry.hy_than));
981        }
982    }
983
984    #[test]
985    fn validates_na_am_cycle_examples() {
986        let data = baseline_data();
987        assert_eq!(
988            data.sexagenary_na_am
989                .get("Giáp Tý")
990                .map(|v| v.na_am.as_str()),
991            Some("Hải Trung Kim")
992        );
993        assert_eq!(
994            data.sexagenary_na_am
995                .get("Ất Sửu")
996                .map(|v| v.na_am.as_str()),
997            Some("Hải Trung Kim")
998        );
999        assert_eq!(
1000            data.sexagenary_na_am
1001                .get("Bính Dần")
1002                .map(|v| v.na_am.as_str()),
1003            Some("Lư Trung Hỏa")
1004        );
1005    }
1006
1007    #[test]
1008    fn validates_star_rule_schema_loads() {
1009        let data = baseline_data();
1010        assert!(data.star_rule_meta.fixed_by_chi.source_id == "khcbppt");
1011        assert!(data.star_rules_fixed_by_canchi.contains_key("Giáp Thìn"));
1012        assert!(data.star_rules_by_year_can.contains_key("Giáp"));
1013        assert!(data.star_rules_by_lunar_month.contains_key(&1));
1014        assert!(data.star_rules_by_tiet_khi.contains_key("Lập Xuân"));
1015    }
1016
1017    #[test]
1018    fn validates_day_deity_rule_schema_loads() {
1019        let data = baseline_data();
1020        assert_eq!(data.day_deity_meta.source_id, "khcbppt");
1021        assert_eq!(data.day_deity_rule_set.cycle.len(), 12);
1022        assert_eq!(data.day_deity_rule_set.cycle[0].name, "Thanh Long");
1023        assert_eq!(
1024            data.day_deity_rule_set
1025                .month_group_start_by_chi
1026                .get("Dần")
1027                .copied(),
1028            Some(0)
1029        );
1030        assert_eq!(
1031            data.day_deity_rule_set
1032                .month_group_start_by_chi
1033                .get("Tý")
1034                .copied(),
1035            Some(8)
1036        );
1037    }
1038
1039    #[test]
1040    fn resolves_default_ruleset_by_canonical_id() {
1041        let entry = get_ruleset(DEFAULT_RULESET_ID).expect("canonical ruleset lookup");
1042        assert_eq!(entry.descriptor.id, "vn_baseline_v1");
1043        assert_eq!(entry.descriptor.version, "v1");
1044        assert_eq!(entry.descriptor.region, "vn");
1045        assert_eq!(entry.descriptor.profile, "baseline");
1046    }
1047
1048    #[test]
1049    fn resolves_baseline_alias_to_same_ruleset() {
1050        let alias = get_ruleset("baseline").expect("alias lookup");
1051        let canonical = get_ruleset(DEFAULT_RULESET_ID).expect("canonical lookup");
1052        assert_eq!(alias.descriptor.id, canonical.descriptor.id);
1053        assert!(std::ptr::eq(alias.data(), canonical.data()));
1054    }
1055
1056    #[test]
1057    fn rejects_unknown_ruleset_id() {
1058        let err = get_ruleset("does-not-exist").expect_err("unknown ruleset must fail");
1059        assert_eq!(
1060            err.to_string(),
1061            "unknown almanac ruleset id: does-not-exist"
1062        );
1063    }
1064
1065    #[test]
1066    fn baseline_loader_shim_matches_default_registry_entry() {
1067        assert!(std::ptr::eq(baseline_data(), default_ruleset().data()));
1068        assert!(std::ptr::eq(
1069            baseline_data(),
1070            get_ruleset_data(DEFAULT_RULESET_ID).expect("ruleset data")
1071        ));
1072    }
1073
1074    #[test]
1075    fn builds_ruleset_descriptor_doc_with_required_fields() {
1076        let descriptor = get_ruleset_descriptor_doc(DEFAULT_RULESET_ID).expect("descriptor doc");
1077        assert_eq!(descriptor.id, "vn_baseline_v1");
1078        assert_eq!(descriptor.version, "v1");
1079        assert_eq!(descriptor.region, "vn");
1080        assert_eq!(descriptor.defaults.tz_offset, 7.0);
1081        assert_eq!(descriptor.defaults.meridian, None);
1082        assert_eq!(descriptor.schema_version, "ruleset-descriptor/v1");
1083        assert!(descriptor
1084            .source_notes
1085            .iter()
1086            .any(|note| note.family == "taboo_rules" && note.source_id == "khcbppt"));
1087    }
1088
1089    #[test]
1090    fn descriptor_doc_loader_accepts_alias_path() {
1091        let alias = get_ruleset_descriptor_doc("baseline").expect("alias descriptor");
1092        let canonical =
1093            get_ruleset_descriptor_doc(DEFAULT_RULESET_ID).expect("canonical descriptor");
1094        assert_eq!(alias, canonical);
1095    }
1096
1097    #[test]
1098    fn rejects_invalid_ruleset_descriptor_doc_region() {
1099        let mut descriptor = default_ruleset().descriptor.to_document_descriptor();
1100        descriptor.region = "cn".to_string();
1101
1102        let result = std::panic::catch_unwind(|| validate_ruleset_descriptor_doc(&descriptor));
1103        assert!(result.is_err(), "invalid region must fail validation");
1104    }
1105
1106    #[test]
1107    fn rejects_invalid_ruleset_descriptor_doc_tokens() {
1108        let mut descriptor = default_ruleset().descriptor.to_document_descriptor();
1109        descriptor.defaults.tz_offset = 20.0;
1110        descriptor.source_notes.push(RuleSetSourceNote {
1111            family: "taboo_rules".to_string(),
1112            source_id: "".to_string(),
1113            note: "dup family and empty source".to_string(),
1114        });
1115
1116        let result = std::panic::catch_unwind(|| validate_ruleset_descriptor_doc(&descriptor));
1117        assert!(
1118            result.is_err(),
1119            "invalid descriptor tokens must fail validation"
1120        );
1121    }
1122
1123    #[test]
1124    fn validates_taboo_rule_schema_loads() {
1125        let data = baseline_data();
1126        assert_eq!(data.taboo_rule_meta.tam_nuong.method, "table-lookup");
1127        assert_eq!(data.taboo_rules.tam_nuong.rule_id, "tam_nuong");
1128        assert_eq!(data.taboo_rules.nguyet_ky.lunar_days, vec![5, 14, 23]);
1129        assert_eq!(
1130            data.taboo_rules
1131                .sat_chu
1132                .by_lunar_month
1133                .get(&1)
1134                .map(String::as_str),
1135            Some("Tỵ")
1136        );
1137        assert_eq!(
1138            data.taboo_rules
1139                .tho_tu
1140                .by_lunar_month
1141                .get(&12)
1142                .map(String::as_str),
1143            Some("Mùi")
1144        );
1145    }
1146
1147    #[test]
1148    fn rejects_invalid_taboo_day_rule_values() {
1149        let bad = TabooDayRuleRaw {
1150            rule_id: "tam_nuong".to_string(),
1151            name: "Tam Nương".to_string(),
1152            severity: "critical".to_string(),
1153            lunar_days: vec![3, 31],
1154        };
1155
1156        let result = std::panic::catch_unwind(|| {
1157            validate_taboo_day_rule(&bad, "taboo_rule_sets.tam_nuong", "tam_nuong")
1158        });
1159        assert!(result.is_err(), "invalid taboo day rule must panic");
1160    }
1161
1162    #[test]
1163    fn rejects_invalid_taboo_month_chi_rule_values() {
1164        let mut by_lunar_month = HashMap::new();
1165        for month in 1..=11 {
1166            by_lunar_month.insert(month.to_string(), "Tý".to_string());
1167        }
1168        by_lunar_month.insert("12".to_string(), "NotAChi".to_string());
1169
1170        let bad = TabooMonthChiRuleRaw {
1171            rule_id: "sat_chu".to_string(),
1172            name: "Sát Chủ".to_string(),
1173            severity: "hard".to_string(),
1174            by_lunar_month,
1175        };
1176
1177        let result = std::panic::catch_unwind(|| {
1178            validate_taboo_month_chi_rule(&bad, "taboo_rule_sets.sat_chu", "sat_chu")
1179        });
1180        assert!(result.is_err(), "invalid taboo month-chi rule must panic");
1181    }
1182
1183    #[test]
1184    fn accepts_known_tiet_khi_names() {
1185        assert!(is_valid_tiet_khi_name("Lập Xuân"));
1186        assert!(is_valid_tiet_khi_name("Thanh Minh"));
1187        assert!(is_valid_tiet_khi_name("Đông Chí"));
1188    }
1189
1190    #[test]
1191    fn rejects_unknown_tiet_khi_names() {
1192        assert!(!is_valid_tiet_khi_name("Lap Xuan"));
1193        assert!(!is_valid_tiet_khi_name("Unknown Term"));
1194    }
1195
1196    #[test]
1197    fn validates_sexagenary_key_tokens() {
1198        assert!(is_valid_sexagenary_key("Giáp Tý"));
1199        assert!(is_valid_sexagenary_key("Quý Hợi"));
1200        assert!(!is_valid_sexagenary_key("Giáp Unknown"));
1201        assert!(!is_valid_sexagenary_key("Unknown Tý"));
1202    }
1203}