pub mod keys;
pub mod rules;
pub mod view;
use serde::{Deserialize, Serialize};
use crate::data::stars::StarKey;
use crate::data::types::{Brightness, Mutagen, Scope};
use crate::models::astrolabe::Astrolabe;
use crate::models::horoscope::{HoroscopeData, HoroscopeRef};
pub use keys::{ALL_PATTERNS, PatternKey};
pub use view::ChartView;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BrightnessSource {
#[default]
Table,
Positional,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
pub struct PatternConfig {
pub brightness_source: BrightnessSource,
pub borrow: bool,
pub flow_stars: bool,
}
impl Default for PatternConfig {
fn default() -> Self {
PatternConfig {
brightness_source: BrightnessSource::Table,
borrow: true,
flow_stars: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StarAt {
pub star: StarKey,
pub palace: usize,
pub brightness: Option<Brightness>,
pub mutagen: Option<Mutagen>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatternHit {
pub key: PatternKey,
pub scope: Scope,
pub palace: usize,
pub variant: Option<&'static str>,
pub broken: bool,
pub stars: Vec<StarAt>,
}
pub struct Rule {
pub key: PatternKey,
pub detect: fn(&ChartView) -> Vec<PatternHit>,
}
pub fn detect(view: &ChartView) -> Vec<PatternHit> {
rules::all()
.filter(|r| view.scope() != Scope::Origin || !r.key.is_horoscope_only())
.flat_map(|r| (r.detect)(view))
.collect()
}
impl Astrolabe {
pub fn patterns(&self) -> Vec<PatternHit> {
self.patterns_with(&PatternConfig::default())
}
pub fn patterns_with(&self, config: &PatternConfig) -> Vec<PatternHit> {
detect(&ChartView::natal(self, config))
}
}
impl<'a> HoroscopeRef<'a> {
pub fn patterns(&self, scope: Scope) -> Vec<PatternHit> {
self.patterns_with(scope, &PatternConfig::default())
}
pub fn patterns_with(&self, scope: Scope, config: &PatternConfig) -> Vec<PatternHit> {
patterns_at(self.astrolabe(), self.data(), scope, config)
}
}
pub fn patterns_at(
astrolabe: &Astrolabe,
horoscope: &HoroscopeData,
scope: Scope,
config: &PatternConfig,
) -> Vec<PatternHit> {
detect(&ChartView::at(astrolabe, horoscope, scope, config))
}
#[cfg(test)]
pub(crate) mod testutil {
use super::*;
use crate::astro::builder::by_solar;
use crate::data::types::{Config, Gender, Language};
pub(crate) fn find_chart(pred: impl Fn(&Astrolabe) -> bool) -> Astrolabe {
find_charts(1, pred).pop().unwrap()
}
pub(crate) fn find_charts(n: usize, pred: impl Fn(&Astrolabe) -> bool) -> Vec<Astrolabe> {
let mut out = Vec::new();
for y in 1950..2021 {
for m in 1..=12 {
for d in 1..=28 {
for h in 0..12u8 {
for g in [Gender::Male, Gender::Female] {
let a = by_solar(
&format!("{y}-{m}-{d}"),
h,
g,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
if pred(&a) {
out.push(a);
if out.len() == n {
return out;
}
}
}
}
}
}
}
assert!(!out.is_empty(), "no chart found");
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::astro::builder::by_solar;
use crate::data::types::{Config, Gender, Language, Palace};
use super::testutil::find_chart;
fn soul(a: &Astrolabe) -> &crate::models::palace::PalaceData {
a.palace(Palace::Soul).unwrap().data()
}
#[test]
fn zi_fu_tong_gong_hits_only_when_both_in_soul() {
let a = find_chart(|a| soul(a).has(&[StarKey::ZiweiMaj, StarKey::TianfuMaj]));
let hits = a.patterns();
let hit = hits
.iter()
.find(|h| h.key == PatternKey::ZiFuTongGong)
.expect("hit");
assert_eq!(hit.palace, soul(&a).index);
assert_eq!(hit.scope, Scope::Origin);
assert_eq!(hit.stars.len(), 2);
assert!(matches!(
soul(&a).earthly_branch,
crate::data::types::EarthlyBranch::Yin | crate::data::types::EarthlyBranch::Shen
));
let b = find_chart(|a| {
soul(a).has(&[StarKey::ZiweiMaj]) && !soul(a).has(&[StarKey::TianfuMaj])
});
assert!(
!b.patterns()
.iter()
.any(|h| h.key == PatternKey::ZiFuTongGong)
);
}
#[test]
fn zuo_you_jia_ming_requires_both_neighbours() {
let a = find_chart(|a| {
let s = soul(a).index;
let p = &a.palaces[(s + 11) % 12];
let n = &a.palaces[(s + 1) % 12];
(p.has(&[StarKey::ZuofuMin]) && n.has(&[StarKey::YoubiMin]))
|| (p.has(&[StarKey::YoubiMin]) && n.has(&[StarKey::ZuofuMin]))
});
let hits = a.patterns();
let hit = hits
.iter()
.find(|h| h.key == PatternKey::ZuoYouJiaMing)
.expect("hit");
let palaces: Vec<_> = hit.stars.iter().map(|s| s.palace).collect();
assert!(
palaces.contains(&((hit.palace + 11) % 12))
&& palaces.contains(&((hit.palace + 1) % 12))
);
}
#[test]
fn ming_lu_an_lu_uses_hidden_palace() {
let a = find_chart(|a| {
let v = ChartView::natal(a, &PatternConfig::default());
let s = v.soul();
let h = v.hidden(s);
v.has(s, StarKey::LucunMin) && v.has_mutagen(h, Mutagen::Lu)
});
assert!(a.patterns().iter().any(|h| h.key == PatternKey::MingLuAnLu));
}
#[test]
fn hidden_palace_is_six_harmony() {
let a = by_solar(
"2000-8-16",
2,
Gender::Female,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
let v = ChartView::natal(&a, &PatternConfig::default());
use crate::data::types::EarthlyBranch::*;
for (i, want) in (0..12).map(|i| (i, v.branch(i))).map(|(i, b)| {
let w = match b {
Zi => Chou,
Chou => Zi,
Yin => Hai,
Hai => Yin,
Mao => Xu,
Xu => Mao,
Chen => You,
You => Chen,
Si => Shen,
Shen => Si,
Wu => Wei,
Wei => Wu,
};
(i, w)
}) {
assert_eq!(v.branch(v.hidden(i)), want);
}
}
#[test]
fn horoscope_view_uses_scope_soul_and_mutagen() {
let a = by_solar(
"2000-8-16",
2,
Gender::Female,
true,
Language::ZhCN,
Config::default(),
)
.unwrap();
let h = a.horoscope("2026-8-19", 3).unwrap();
let cfg = PatternConfig::default();
let v = ChartView::at(&a, h.data(), Scope::Decadal, &cfg);
assert_eq!(v.soul(), h.decadal.index);
assert_eq!(v.body(), None);
assert_eq!(v.name_of(v.soul()), Palace::Soul);
let lu = h.decadal.mutagen[0];
let (p, star) = v.locate(lu).expect("mutagen star on chart");
assert_eq!(v.mutagen_of(star), Some(Mutagen::Lu));
assert!(v.has_mutagen(p, Mutagen::Lu));
let _ = h.patterns(Scope::Decadal);
let _ = h.patterns(Scope::Yearly);
assert_eq!(h.patterns(Scope::Origin), a.patterns());
}
#[test]
fn positional_brightness_flips_sun_moon_judgement() {
let a = find_chart(|a| {
a.palaces.iter().any(|p| {
p.earthly_branch == crate::data::types::EarthlyBranch::You
&& p.has(&[StarKey::TaiyinMaj])
})
});
let table = ChartView::natal(&a, &PatternConfig::default());
let pos = ChartView::natal(
&a,
&PatternConfig {
brightness_source: BrightnessSource::Positional,
..Default::default()
},
);
let you = (0..12)
.find(|i| table.branch(*i) == crate::data::types::EarthlyBranch::You)
.unwrap();
assert!(!table.sun_moon_bright(you, StarKey::TaiyinMaj));
assert!(pos.sun_moon_bright(you, StarKey::TaiyinMaj));
}
}