iztro 0.9.0

Strongly typed Zi Wei Dou Shu chart generation aligned with iztro.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Minimal solar-date facade over the supported chart-generation slice.
//!
//! [`by_solar`] mirrors iztro's `astro.bySolar(...)` conceptually through the
//! typed [`SolarChartRequest`]. It is an adaptor layer: it validates the solar
//! input, converts it to lunar facts with the internal `core/calendar` adapter,
//! then delegates to [`by_lunar`] so chart construction reuses exactly the same
//! supported slice. No new star-placement logic lives here.

use crate::core::calculation::YearBoundary;
use crate::core::calendar::{LunarConversion, solar_to_lunar_with_year_boundary};
use crate::core::error::ChartError;
use crate::core::facade::by_lunar::{LunarChartRequest, by_lunar};
use crate::core::model::calendar::{BirthTime, Gender, SolarDay, SolarMonth};
use crate::core::model::chart::{Chart, HoroscopeLunarDate, HoroscopeSolarDate, NatalDateFacts};
use crate::core::model::ganzhi::EarthlyBranch;
use crate::core::model::profile::{ChartPlane, MethodProfile};

/// Typed solar-date request for the iztro-compatible natal chart facade.
///
/// This mirrors iztro's `bySolar` conceptually while keeping explicit Rust
/// domain types. Unlike [`LunarChartRequest`], the birth year stem/branch and the
/// lunar date are derived during calendar conversion rather than supplied.
///
/// `chart_plane` defaults to [`ChartPlane::Heaven`], reproducing existing
/// chart-generation behaviour, and is propagated into the downstream
/// [`LunarChartRequest`]. The Zhongzhou (中州) family also supports `Earth` and
/// `Human`, generated by re-anchoring the Life Palace; invalid combinations
/// return
/// [`ChartError::UnsupportedChartPlane`](crate::core::error::ChartError::UnsupportedChartPlane).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolarChartRequest {
    solar_year: i32,
    solar_month: SolarMonth,
    solar_day: SolarDay,
    birth_time: BirthTime,
    gender: Gender,
    fix_leap: bool,
    year_boundary: YearBoundary,
    method_profile: MethodProfile,
    chart_plane: ChartPlane,
}

impl SolarChartRequest {
    /// Starts building a typed solar chart request.
    ///
    /// Set each required field on the returned builder, then call
    /// [`SolarChartRequestBuilder::build`].
    pub fn builder() -> SolarChartRequestBuilder {
        SolarChartRequestBuilder::default()
    }

    /// Returns the provided solar (Gregorian) year.
    pub const fn solar_year(&self) -> i32 {
        self.solar_year
    }

    /// Returns the validated solar month.
    pub const fn solar_month(&self) -> SolarMonth {
        self.solar_month
    }

    /// Returns the validated solar day.
    pub const fn solar_day(&self) -> SolarDay {
        self.solar_day
    }

    /// Returns the birth time branch.
    pub const fn birth_time(&self) -> EarthlyBranch {
        self.birth_time.branch()
    }

    /// Returns the full birth-time variant.
    pub const fn birth_time_variant(&self) -> BirthTime {
        self.birth_time
    }

    /// Returns the gender marker.
    pub const fn gender(&self) -> Gender {
        self.gender
    }

    /// Returns whether leap-month adjustment is applied (调整闰月).
    pub const fn fix_leap(&self) -> bool {
        self.fix_leap
    }

    /// Returns the 年分界 calculation policy used for the birth-year pillar.
    ///
    /// Defaults to [`YearBoundary::ChineseNewYearEve`] when not set on the builder.
    pub const fn year_boundary(&self) -> YearBoundary {
        self.year_boundary
    }

    /// Returns the method profile metadata.
    pub const fn method_profile(&self) -> &MethodProfile {
        &self.method_profile
    }

    /// Returns the requested chart plane (天盘 / 地盘 / 人盘).
    ///
    /// Defaults to [`ChartPlane::Heaven`] when not set on the builder.
    pub const fn chart_plane(&self) -> ChartPlane {
        self.chart_plane
    }
}

/// Builder for [`SolarChartRequest`].
///
/// Each field is optional until set; [`build`](SolarChartRequestBuilder::build)
/// fails with [`ChartError::MissingRequiredInput`] when a required field is
/// missing. `fix_leap` defaults to `true`, matching upstream `iztro@2.5.8`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolarChartRequestBuilder {
    solar_year: Option<i32>,
    solar_month: Option<SolarMonth>,
    solar_day: Option<SolarDay>,
    birth_time: Option<BirthTime>,
    gender: Option<Gender>,
    fix_leap: Option<bool>,
    year_boundary: Option<YearBoundary>,
    method_profile: Option<MethodProfile>,
    chart_plane: Option<ChartPlane>,
}

impl SolarChartRequestBuilder {
    /// Sets the solar (Gregorian) year.
    pub fn solar_year(mut self, value: i32) -> Self {
        self.solar_year = Some(value);
        self
    }

    /// Sets the validated solar month.
    pub fn solar_month(mut self, value: SolarMonth) -> Self {
        self.solar_month = Some(value);
        self
    }

    /// Sets the validated solar day.
    pub fn solar_day(mut self, value: SolarDay) -> Self {
        self.solar_day = Some(value);
        self
    }

    /// Sets the birth time branch.
    pub fn birth_time(mut self, value: EarthlyBranch) -> Self {
        self.birth_time = Some(BirthTime::from_branch(value));
        self
    }

    /// Sets the full birth-time variant.
    pub fn birth_time_variant(mut self, value: BirthTime) -> Self {
        self.birth_time = Some(value);
        self
    }

    /// Sets the birth time from an upstream `iztro` `timeIndex`.
    pub fn iztro_time_index(mut self, value: u8) -> Result<Self, ChartError> {
        self.birth_time = Some(BirthTime::from_iztro_time_index(value)?);
        Ok(self)
    }

    /// Sets the gender marker.
    pub fn gender(mut self, value: Gender) -> Self {
        self.gender = Some(value);
        self
    }

    /// Sets whether leap-month adjustment is applied (调整闰月).
    ///
    /// Defaults to `true` when unset, matching upstream `iztro@2.5.8`.
    pub fn fix_leap(mut self, value: bool) -> Self {
        self.fix_leap = Some(value);
        self
    }

    /// Sets the 年分界 calculation policy used for the birth-year pillar.
    ///
    /// Defaults to [`YearBoundary::ChineseNewYearEve`] when unset, reproducing
    /// existing lunar-new-year-bounded behaviour.
    pub const fn year_boundary(mut self, value: YearBoundary) -> Self {
        self.year_boundary = Some(value);
        self
    }

    /// Sets the method profile metadata.
    pub fn method_profile(mut self, value: MethodProfile) -> Self {
        self.method_profile = Some(value);
        self
    }

    /// Sets the requested chart plane (天盘 / 地盘 / 人盘).
    ///
    /// Defaults to [`ChartPlane::Heaven`] when unset. The selected plane is
    /// propagated into the downstream [`LunarChartRequest`]. `Earth` and `Human`
    /// are only valid for the Zhongzhou (中州) family, where they are generated
    /// by re-anchoring the Life Palace; requesting them for other families
    /// returns [`ChartError::UnsupportedChartPlane`].
    pub const fn chart_plane(mut self, chart_plane: ChartPlane) -> Self {
        self.chart_plane = Some(chart_plane);
        self
    }

    /// Builds the immutable request, requiring every field except `fix_leap`.
    pub fn build(self) -> Result<SolarChartRequest, ChartError> {
        Ok(SolarChartRequest {
            solar_year: self.solar_year.ok_or(ChartError::MissingRequiredInput {
                field: "solar_year",
            })?,
            solar_month: self.solar_month.ok_or(ChartError::MissingRequiredInput {
                field: "solar_month",
            })?,
            solar_day: self
                .solar_day
                .ok_or(ChartError::MissingRequiredInput { field: "solar_day" })?,
            birth_time: self.birth_time.ok_or(ChartError::MissingRequiredInput {
                field: "birth_time",
            })?,
            gender: self
                .gender
                .ok_or(ChartError::MissingRequiredInput { field: "gender" })?,
            fix_leap: self.fix_leap.unwrap_or(true),
            year_boundary: self.year_boundary.unwrap_or_default(),
            method_profile: self
                .method_profile
                .ok_or(ChartError::MissingRequiredInput {
                    field: "method_profile",
                })?,
            chart_plane: self.chart_plane.unwrap_or_default(),
        })
    }
}

/// Builds a natal chart with currently supported natal stars from a solar date.
///
/// This facade validates the Gregorian/solar date, converts it to Chinese
/// lunisolar facts with the internal `core/calendar` adapter, derives the
/// birth-year stem/branch from the cyclic year, retains the factual natal four
/// pillars, sets `is_leap_month` from the conversion and `fix_leap` from the
/// request, then delegates star placement to [`by_lunar`]. It adds no placement
/// logic of its own, so it preserves the exact `by_lunar` supported slice
/// (including leap-month behavior).
pub fn by_solar(request: SolarChartRequest) -> Result<Chart, ChartError> {
    let conversion = solar_to_lunar_with_year_boundary(
        request.solar_year(),
        request.solar_month(),
        request.solar_day(),
        request.birth_time_variant().iztro_time_index(),
        request.year_boundary(),
    )?;

    by_solar_with_conversion(request, conversion)
}

pub(crate) fn by_solar_with_conversion(
    request: SolarChartRequest,
    conversion: LunarConversion,
) -> Result<Chart, ChartError> {
    let lunar_request = LunarChartRequest::builder()
        .lunar_year(conversion.lunar_year())
        .lunar_month(conversion.lunar_month())
        .lunar_day(conversion.lunar_day())
        .birth_time_variant(request.birth_time_variant())
        .gender(request.gender())
        .birth_year_stem(conversion.birth_year_stem())
        .birth_year_branch(conversion.birth_year_branch())
        .is_leap_month(conversion.is_leap_month())
        .fix_leap(request.fix_leap())
        .method_profile(request.method_profile().clone())
        .chart_plane(request.chart_plane())
        .build()?;

    let chart = by_lunar(lunar_request)?;
    let natal_dates = NatalDateFacts::new(
        HoroscopeSolarDate::new(
            request.solar_year(),
            request.solar_month().value(),
            request.solar_day().value(),
        ),
        HoroscopeLunarDate::new(
            conversion.lunar_year(),
            conversion.lunar_month().value(),
            conversion.lunar_day().value(),
            conversion.is_leap_month(),
        ),
    );
    Ok(Chart::try_new_with_four_pillars_and_profile(
        chart.birth_context().clone(),
        chart.birth_year(),
        Some(conversion.four_pillars()),
        chart.chart_profile().clone(),
        chart.palaces().to_vec(),
        chart.body_palace_branch(),
        chart.five_element_bureau(),
    )?
    .with_natal_date_facts(natal_dates))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::model::profile::ChartAlgorithmKind;

    fn base_builder(profile: MethodProfile) -> SolarChartRequestBuilder {
        SolarChartRequest::builder()
            .solar_year(1990)
            .solar_month(SolarMonth::new(6).expect("valid solar month"))
            .solar_day(SolarDay::new(15).expect("valid solar day"))
            .birth_time(EarthlyBranch::Chen)
            .gender(Gender::Female)
            .method_profile(profile)
    }

    fn quanshu_profile() -> MethodProfile {
        MethodProfile::new("quanshu_test", ChartAlgorithmKind::QuanShu, "quanshu test")
    }

    fn zhongzhou_profile() -> MethodProfile {
        MethodProfile::new(
            "zhongzhou_test",
            ChartAlgorithmKind::Zhongzhou,
            "zhongzhou test",
        )
    }

    #[test]
    fn chart_plane_defaults_to_heaven() {
        let request = base_builder(quanshu_profile())
            .build()
            .expect("request should build");

        assert_eq!(request.chart_plane(), ChartPlane::Heaven);
    }

    #[test]
    fn explicit_chart_plane_is_preserved() {
        let request = base_builder(zhongzhou_profile())
            .chart_plane(ChartPlane::Earth)
            .build()
            .expect("request should build");

        assert_eq!(request.chart_plane(), ChartPlane::Earth);
    }

    #[test]
    fn chart_plane_propagates_into_lunar_request_path() {
        // A Zhongzhou + Earth solar request must produce a chart that differs
        // from the Heaven plane, proving the chart plane is propagated
        // downstream rather than dropped.
        let earth = by_solar(
            base_builder(zhongzhou_profile())
                .chart_plane(ChartPlane::Earth)
                .build()
                .expect("earth request should build"),
        )
        .expect("zhongzhou earth solar request should build");

        let heaven = by_solar(
            base_builder(zhongzhou_profile())
                .chart_plane(ChartPlane::Heaven)
                .build()
                .expect("heaven request should build"),
        )
        .expect("zhongzhou heaven solar request should build");

        assert_ne!(earth, heaven);
    }

    #[test]
    fn zhongzhou_earth_solar_request_anchors_to_heaven_body_palace() {
        use crate::core::model::chart::PalaceName;

        let solar_chart = |plane: ChartPlane| {
            by_solar(
                base_builder(zhongzhou_profile())
                    .chart_plane(plane)
                    .build()
                    .expect("request should build"),
            )
            .expect("zhongzhou solar chart should build")
        };

        let heaven = solar_chart(ChartPlane::Heaven);
        let earth = solar_chart(ChartPlane::Earth);
        let human = solar_chart(ChartPlane::Human);

        let life_branch = |chart: &Chart| {
            chart
                .life_palace()
                .expect("chart should have a Life Palace")
                .branch()
        };
        let fortune_branch = |chart: &Chart| {
            chart
                .palace_by_name(PalaceName::Spirit)
                .expect("chart should have a Fortune Palace")
                .branch()
        };

        assert_eq!(
            life_branch(&earth),
            heaven
                .body_palace_branch()
                .expect("heaven chart should have a Body Palace branch"),
        );
        assert_eq!(life_branch(&human), fortune_branch(&heaven));
    }

    #[test]
    fn solar_request_chart_carries_requested_plane() {
        // by_solar reconstructs the chart after delegating to by_lunar; this
        // guards against accidentally resetting the plane to Heaven during that
        // final reconstruction.
        let solar_chart = |plane: ChartPlane| {
            by_solar(
                base_builder(zhongzhou_profile())
                    .chart_plane(plane)
                    .build()
                    .expect("request should build"),
            )
            .expect("zhongzhou solar chart should build")
        };

        for plane in [ChartPlane::Heaven, ChartPlane::Earth, ChartPlane::Human] {
            let chart = solar_chart(plane);
            assert_eq!(chart.chart_plane(), plane);
            assert_eq!(
                chart.method_profile().algorithm_kind(),
                ChartAlgorithmKind::Zhongzhou,
            );
        }
    }

    #[test]
    fn quanshu_earth_solar_request_is_unsupported() {
        let request = base_builder(quanshu_profile())
            .chart_plane(ChartPlane::Earth)
            .build()
            .expect("request should build");

        assert_eq!(
            by_solar(request),
            Err(ChartError::UnsupportedChartPlane {
                algorithm: ChartAlgorithmKind::QuanShu,
                plane: ChartPlane::Earth,
            }),
        );
    }
}