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}