use thiserror::Error;
pub const HEAVENLY_STEMS: [HeavenlyStem; 10] = [
HeavenlyStem::Jia,
HeavenlyStem::Yi,
HeavenlyStem::Bing,
HeavenlyStem::Ding,
HeavenlyStem::Wu,
HeavenlyStem::Ji,
HeavenlyStem::Geng,
HeavenlyStem::Xin,
HeavenlyStem::Ren,
HeavenlyStem::Gui,
];
pub const EARTHLY_BRANCHES: [EarthlyBranch; 12] = [
EarthlyBranch::Zi,
EarthlyBranch::Chou,
EarthlyBranch::Yin,
EarthlyBranch::Mao,
EarthlyBranch::Chen,
EarthlyBranch::Si,
EarthlyBranch::Wu,
EarthlyBranch::Wei,
EarthlyBranch::Shen,
EarthlyBranch::You,
EarthlyBranch::Xu,
EarthlyBranch::Hai,
];
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HeavenlyStem {
Jia,
Yi,
Bing,
Ding,
Wu,
Ji,
Geng,
Xin,
Ren,
Gui,
}
impl HeavenlyStem {
pub const fn index(self) -> usize {
match self {
Self::Jia => 0,
Self::Yi => 1,
Self::Bing => 2,
Self::Ding => 3,
Self::Wu => 4,
Self::Ji => 5,
Self::Geng => 6,
Self::Xin => 7,
Self::Ren => 8,
Self::Gui => 9,
}
}
pub fn from_index(index: usize) -> Self {
HEAVENLY_STEMS[index % HEAVENLY_STEMS.len()]
}
pub fn offset(self, delta: isize) -> Self {
let len = HEAVENLY_STEMS.len() as isize;
let index = (self.index() as isize + delta).rem_euclid(len) as usize;
Self::from_index(index)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EarthlyBranch {
Zi,
Chou,
Yin,
Mao,
Chen,
Si,
Wu,
Wei,
Shen,
You,
Xu,
Hai,
}
impl EarthlyBranch {
pub const fn index(self) -> usize {
match self {
Self::Zi => 0,
Self::Chou => 1,
Self::Yin => 2,
Self::Mao => 3,
Self::Chen => 4,
Self::Si => 5,
Self::Wu => 6,
Self::Wei => 7,
Self::Shen => 8,
Self::You => 9,
Self::Xu => 10,
Self::Hai => 11,
}
}
pub fn from_index(index: usize) -> Self {
EARTHLY_BRANCHES[index % EARTHLY_BRANCHES.len()]
}
pub fn offset(self, delta: isize) -> Self {
let len = EARTHLY_BRANCHES.len() as isize;
let index = (self.index() as isize + delta).rem_euclid(len) as usize;
Self::from_index(index)
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum StemBranchError {
#[error("Invalid stem-branch pair: {stem:?} - {branch:?}")]
InvalidStemBranchPair {
stem: HeavenlyStem,
branch: EarthlyBranch,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize)]
pub struct StemBranch {
stem: HeavenlyStem,
branch: EarthlyBranch,
}
impl<'de> serde::Deserialize<'de> for StemBranch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct RawStemBranch {
stem: HeavenlyStem,
branch: EarthlyBranch,
}
let raw = RawStemBranch::deserialize(deserializer)?;
StemBranch::try_new(raw.stem, raw.branch).map_err(serde::de::Error::custom)
}
}
impl StemBranch {
pub fn try_new(stem: HeavenlyStem, branch: EarthlyBranch) -> Result<Self, StemBranchError> {
if stem.index() % 2 == branch.index() % 2 {
Ok(Self { stem, branch })
} else {
Err(StemBranchError::InvalidStemBranchPair { stem, branch })
}
}
pub fn from_cycle_index(index: usize) -> Self {
let index = index % 60;
Self {
stem: HeavenlyStem::from_index(index),
branch: EarthlyBranch::from_index(index),
}
}
pub fn from_lunar_year(year: i32) -> Self {
let index = (year - 1984).rem_euclid(60) as usize;
Self::from_cycle_index(index)
}
pub const fn stem(&self) -> HeavenlyStem {
self.stem
}
pub const fn branch(&self) -> EarthlyBranch {
self.branch
}
pub fn cycle_index(&self) -> usize {
(0..60)
.find(|&index| index % 10 == self.stem.index() && index % 12 == self.branch.index())
.expect("StemBranch invariant guarantees a valid cycle index")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
pub struct FourPillars {
pub yearly: StemBranch,
pub monthly: StemBranch,
pub daily: StemBranch,
pub hourly: StemBranch,
}
pub fn lunar_year_stem_branch(lunar_year: i32) -> StemBranch {
StemBranch::from_lunar_year(lunar_year)
}
pub fn lunar_year_stem(lunar_year: i32) -> HeavenlyStem {
StemBranch::from_lunar_year(lunar_year).stem()
}
pub fn lunar_year_branch(lunar_year: i32) -> EarthlyBranch {
StemBranch::from_lunar_year(lunar_year).branch()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stem_branch_rejects_mismatched_parity() {
assert!(StemBranch::try_new(HeavenlyStem::Jia, EarthlyBranch::Chou).is_err());
assert!(StemBranch::try_new(HeavenlyStem::Jia, EarthlyBranch::Zi).is_ok());
}
#[test]
fn from_lunar_year_anchors_1984_jiazi() {
let jiazi = StemBranch::from_lunar_year(1984);
assert_eq!(jiazi.stem(), HeavenlyStem::Jia);
assert_eq!(jiazi.branch(), EarthlyBranch::Zi);
let y2024 = StemBranch::from_lunar_year(2024);
assert_eq!(y2024.stem(), HeavenlyStem::Jia);
assert_eq!(y2024.branch(), EarthlyBranch::Chen);
let y2023 = StemBranch::from_lunar_year(2023);
assert_eq!(y2023.stem(), HeavenlyStem::Gui);
assert_eq!(y2023.branch(), EarthlyBranch::Mao);
}
#[test]
fn cycle_index_round_trips() {
for index in 0..60 {
assert_eq!(StemBranch::from_cycle_index(index).cycle_index(), index);
}
}
#[test]
fn lunar_year_helpers_agree_with_pillar_accessors() {
for year in [1850, 1984, 2000, 2023, 2150] {
let pillar = StemBranch::from_lunar_year(year);
assert_eq!(lunar_year_stem_branch(year), pillar);
assert_eq!(lunar_year_stem(year), pillar.stem());
assert_eq!(lunar_year_branch(year), pillar.branch());
}
}
}