x-iztro 0.3.0

Zi Wei Dou Shu (Purple Star Astrology) chart engine, field-for-field identical to 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
449
450
451
452
453
454
455
456
457
458
459
460
461
//! 知识包:语言无关标识 → 解读文本与门派属性。
//!
//! 内核只负责事实判定,星耀怎么解读、格局意味着什么、宫位与四化的含义属于门派观点,
//! 全部放在知识包里。格式见仓库 `knowledge/SCHEMA.md`;内核内嵌一份默认包
//! (源自 iztro-docs《学习》页,MIT),使用者可整包替换或用覆盖包逐条合并。
//!
//! 所有键都是 x-iztro 的语言无关标识:`StarKey::as_key`、`PatternKey::as_key`、
//! `Palace::as_key`、`Mutagen::as_key`;文本字段为 Markdown。

use std::collections::BTreeMap;
use std::sync::OnceLock;

use serde::{Deserialize, Serialize};

use crate::data::stars::StarKey;
use crate::data::types::{Language, Mutagen, Palace};
use crate::pattern::PatternKey;

/// 当前支持的知识包格式版本。
pub const SCHEMA_VERSION: u32 = 1;

/// 包的来源与许可信息。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Source {
    /// 来源名称
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// 来源地址
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// 来源版本(如 git commit)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub commit: Option<String>,
    /// 许可证
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    /// 作者
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
    /// 取得日期
    #[serde(skip_serializing_if = "Option::is_none")]
    pub retrieved_at: Option<String>,
    /// 改写说明:文本经过整理改写时注明(如「文本由 x-iztro 在原文基础上整理改写」)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub adapted: Option<String>,
}

/// 星耀的门派属性(全部可选)。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StarAttributes {
    /// 阴阳(`yin` / `yang`)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub yin_yang: Option<String>,
    /// 五行(`wood` / `fire` / `earth` / `metal` / `water`)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub five_elements: Option<String>,
    /// 五行所带天干(`jia`…`gui`)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stem: Option<String>,
    /// 五行的补充说明(如「气为水」)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub five_elements_note: Option<String>,
    /// 斗分(如「中天星系」「南斗第三星」)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dipper: Option<String>,
    /// 化气(如「尊贵」「善」)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub chemistry: Option<String>,
    /// 职业(主何事,如「官禄主」)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub career: Option<String>,
    /// 职务
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duty: Option<String>,
    /// 别号
    #[serde(skip_serializing_if = "Option::is_none")]
    pub aliases: Option<Vec<String>>,
    /// 五行色
    #[serde(skip_serializing_if = "Option::is_none")]
    pub element_color: Option<String>,
    /// 能量色
    #[serde(skip_serializing_if = "Option::is_none")]
    pub energy_color: Option<String>,
}

/// 一颗星耀的知识条目。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StarEntry {
    /// 该语言的显示名
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// 类别(`major` / `minor` / `adjective` / `dec`)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,
    /// 分组(杂耀的分类、神煞的组别)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    /// 门派属性
    #[serde(deserialize_with = "null_as_default")]
    pub attributes: StarAttributes,
    /// 解读正文
    #[serde(skip_serializing_if = "Option::is_none")]
    pub intro: Option<String>,
    /// 与另一颗主星同宫的组合解读,键为对方星耀标识
    #[serde(
        skip_serializing_if = "BTreeMap::is_empty",
        deserialize_with = "null_as_default"
    )]
    pub combinations: BTreeMap<String, String>,
}

/// 一条格局的知识条目。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PatternEntry {
    /// 该语言的显示名
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// 古籍引文
    #[serde(skip_serializing_if = "Option::is_none")]
    pub quotes: Option<Vec<String>>,
    /// 来源对成立条件的文字描述
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conditions: Option<String>,
    /// 解读正文
    #[serde(skip_serializing_if = "Option::is_none")]
    pub intro: Option<String>,
}

/// 只有名称与正文的条目(宫位、四化)。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TextEntry {
    /// 该语言的显示名
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// 正文
    #[serde(skip_serializing_if = "Option::is_none")]
    pub intro: Option<String>,
}

/// 术语与基础概念条目。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ConceptEntry {
    /// 标题
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// 正文
    #[serde(skip_serializing_if = "Option::is_none")]
    pub intro: Option<String>,
}

/// 一份知识包。
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct KnowledgePack {
    /// 格式版本
    pub schema: u32,
    /// 包标识
    pub id: String,
    /// 包版本
    pub version: String,
    /// 文本语言(x-iztro 语言码,如 `zh-CN`)
    pub language: String,
    /// 覆盖包所覆盖的包标识;独立包为 `None`
    pub extends: Option<String>,
    /// 来源与许可
    #[serde(deserialize_with = "null_as_default")]
    pub source: Source,
    /// 星耀条目,键为 `StarKey::as_key`
    #[serde(deserialize_with = "null_as_default")]
    pub stars: BTreeMap<String, StarEntry>,
    /// 格局条目,键为 `PatternKey::as_key`
    #[serde(deserialize_with = "null_as_default")]
    pub patterns: BTreeMap<String, PatternEntry>,
    /// 宫位条目,键为 `Palace::as_key`
    #[serde(deserialize_with = "null_as_default")]
    pub palaces: BTreeMap<String, TextEntry>,
    /// 四化条目,键为 `Mutagen::as_key`
    #[serde(deserialize_with = "null_as_default")]
    pub mutagens: BTreeMap<String, TextEntry>,
    /// 术语与基础概念,键为 slug
    #[serde(deserialize_with = "null_as_default")]
    pub concepts: BTreeMap<String, ConceptEntry>,
}

/// 允许 JSON 里把映射类字段写成 `null`(等同缺省),便于各语言的默认序列化产物直接互通。
fn null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
    D: serde::Deserializer<'de>,
    T: Deserialize<'de> + Default,
{
    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}

/// 内嵌默认包的原文(zh-CN,源自 iztro-docs)。
const BUILTIN_ZH_CN: &str = include_str!("../data/knowledge/iztro_docs.zh-CN.json");

impl KnowledgePack {
    /// 内嵌的默认包;该语言没有默认包时返回 `None`(目前只有 zh-CN)。
    pub fn builtin(language: Language) -> Option<&'static KnowledgePack> {
        static ZH_CN: OnceLock<KnowledgePack> = OnceLock::new();
        match language {
            Language::ZhCN => Some(ZH_CN.get_or_init(|| {
                serde_json::from_str(BUILTIN_ZH_CN).expect("内嵌默认知识包与格式一致")
            })),
            _ => None,
        }
    }

    /// 内嵌默认包的 JSON 原文,供绑定层直接透传。
    pub fn builtin_json(language: Language) -> Option<&'static str> {
        match language {
            Language::ZhCN => Some(BUILTIN_ZH_CN),
            _ => None,
        }
    }

    /// 由 JSON 解析一份包;格式版本高于本库支持的返回错误。
    pub fn from_json(json: &str) -> Result<KnowledgePack, String> {
        let pack: KnowledgePack =
            serde_json::from_str(json).map_err(|e| format!("invalid knowledge pack: {e}"))?;
        if pack.schema == 0 {
            return Err("knowledge pack must declare \"schema\" (currently 1)".to_string());
        }
        if pack.schema > SCHEMA_VERSION {
            return Err(format!(
                "knowledge pack schema {} is newer than supported {SCHEMA_VERSION}",
                pack.schema
            ));
        }
        Ok(pack)
    }

    /// 序列化为 JSON。
    pub fn to_json(&self) -> String {
        serde_json::to_string(self).expect("知识包只含普通可序列化字段")
    }

    /// 把覆盖包合并到本包:逐段按键合并,覆盖包的非空字段覆盖同键条目的对应字段,
    /// `attributes` 与 `combinations` 逐字段合并,数组字段整体替换。
    /// 合并后 `id` / `version` / `language` / `source` 取覆盖包的(若非空),`extends` 保留本包的。
    pub fn merge(&mut self, overlay: &KnowledgePack) {
        for (k, e) in &overlay.stars {
            merge_star(self.stars.entry(k.clone()).or_default(), e);
        }
        for (k, e) in &overlay.patterns {
            merge_pattern(self.patterns.entry(k.clone()).or_default(), e);
        }
        for (k, e) in &overlay.palaces {
            merge_text(self.palaces.entry(k.clone()).or_default(), e);
        }
        for (k, e) in &overlay.mutagens {
            merge_text(self.mutagens.entry(k.clone()).or_default(), e);
        }
        for (k, e) in &overlay.concepts {
            let t = self.concepts.entry(k.clone()).or_default();
            or_set(&mut t.title, &e.title);
            or_set(&mut t.intro, &e.intro);
        }
        if !overlay.id.is_empty() {
            self.id = overlay.id.clone();
        }
        if !overlay.version.is_empty() {
            self.version = overlay.version.clone();
        }
        if !overlay.language.is_empty() {
            self.language = overlay.language.clone();
        }
        if overlay.source != Source::default() {
            self.source = overlay.source.clone();
        }
    }

    /// 本包叠加若干覆盖包后的新包(本包不变)。
    pub fn merged(&self, overlays: &[&KnowledgePack]) -> KnowledgePack {
        let mut out = self.clone();
        for o in overlays {
            out.merge(o);
        }
        out
    }

    /// 星耀条目。
    pub fn star(&self, key: StarKey) -> Option<&StarEntry> {
        self.stars.get(key.as_key())
    }

    /// 格局条目。
    pub fn pattern(&self, key: PatternKey) -> Option<&PatternEntry> {
        self.patterns.get(key.as_key())
    }

    /// 宫位条目。
    pub fn palace(&self, palace: Palace) -> Option<&TextEntry> {
        self.palaces.get(palace.as_key())
    }

    /// 四化条目。
    pub fn mutagen(&self, mutagen: Mutagen) -> Option<&TextEntry> {
        self.mutagens.get(mutagen.as_key())
    }

    /// 星耀解读正文。
    pub fn star_intro(&self, key: StarKey) -> Option<&str> {
        self.star(key)?.intro.as_deref()
    }

    /// 格局解读正文。
    pub fn pattern_intro(&self, key: PatternKey) -> Option<&str> {
        self.pattern(key)?.intro.as_deref()
    }
}

fn or_set<T: Clone>(target: &mut Option<T>, value: &Option<T>) {
    if value.is_some() {
        *target = value.clone();
    }
}

fn merge_star(t: &mut StarEntry, o: &StarEntry) {
    or_set(&mut t.name, &o.name);
    or_set(&mut t.category, &o.category);
    or_set(&mut t.group, &o.group);
    or_set(&mut t.intro, &o.intro);
    let (a, b) = (&mut t.attributes, &o.attributes);
    or_set(&mut a.yin_yang, &b.yin_yang);
    or_set(&mut a.five_elements, &b.five_elements);
    or_set(&mut a.stem, &b.stem);
    or_set(&mut a.five_elements_note, &b.five_elements_note);
    or_set(&mut a.dipper, &b.dipper);
    or_set(&mut a.chemistry, &b.chemistry);
    or_set(&mut a.career, &b.career);
    or_set(&mut a.duty, &b.duty);
    or_set(&mut a.aliases, &b.aliases);
    or_set(&mut a.element_color, &b.element_color);
    or_set(&mut a.energy_color, &b.energy_color);
    for (k, v) in &o.combinations {
        t.combinations.insert(k.clone(), v.clone());
    }
}

fn merge_pattern(t: &mut PatternEntry, o: &PatternEntry) {
    or_set(&mut t.name, &o.name);
    or_set(&mut t.quotes, &o.quotes);
    or_set(&mut t.conditions, &o.conditions);
    or_set(&mut t.intro, &o.intro);
}

fn merge_text(t: &mut TextEntry, o: &TextEntry) {
    or_set(&mut t.name, &o.name);
    or_set(&mut t.intro, &o.intro);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builtin_parses_and_only_zh_cn_exists() {
        let p = KnowledgePack::builtin(Language::ZhCN).expect("zh-CN builtin");
        assert_eq!(p.schema, SCHEMA_VERSION);
        assert_eq!(p.language, "zh-CN");
        assert!(KnowledgePack::builtin(Language::EnUS).is_none());
        assert!(KnowledgePack::builtin_json(Language::ZhCN).is_some());
    }

    #[test]
    fn merge_overrides_fields_and_keeps_others() {
        let base = KnowledgePack::from_json(
            r#"{"schema":1,"id":"base","version":"1","language":"zh-CN",
                "stars":{"ziweiMaj":{"name":"紫微","intro":"底","attributes":{"chemistry":"尊贵","aliases":["帝王星"]},
                          "combinations":{"tianfuMaj":"底组合"}}},
                "patterns":{"zi_fu_tong_gong":{"intro":"底格局","quotes":["a"]}}}"#,
        )
        .unwrap();
        let overlay = KnowledgePack::from_json(
            r#"{"schema":1,"id":"mine","version":"2","language":"zh-CN","extends":"base",
                "stars":{"ziweiMaj":{"intro":"我的","attributes":{"aliases":["我的别号"]},
                          "combinations":{"pojunMaj":"我的组合"}},
                         "tianjiMaj":{"intro":"新星"}},
                "patterns":{"zi_fu_tong_gong":{"quotes":["b","c"]}}}"#,
        )
        .unwrap();
        let m = base.merged(&[&overlay]);
        let zi = m.star(StarKey::ZiweiMaj).unwrap();
        assert_eq!(zi.intro.as_deref(), Some("我的"));
        assert_eq!(zi.name.as_deref(), Some("紫微"));
        assert_eq!(zi.attributes.chemistry.as_deref(), Some("尊贵"));
        assert_eq!(
            zi.attributes.aliases.as_deref(),
            Some(&["我的别号".to_string()][..])
        );
        assert_eq!(zi.combinations.len(), 2);
        assert_eq!(m.star_intro(StarKey::TianjiMaj), Some("新星"));
        let p = m.pattern(PatternKey::ZiFuTongGong).unwrap();
        assert_eq!(p.intro.as_deref(), Some("底格局"));
        assert_eq!(p.quotes.as_ref().unwrap().len(), 2);
        assert_eq!(m.id, "mine");
        assert_eq!(m.extends, None);
        assert_eq!(base.star_intro(StarKey::ZiweiMaj), Some(""));
    }

    /// SCHEMA.md §2/§3/§6 的实现行为承诺:未知顶层字段静默丢弃、未知条目键保留但查询不到、
    /// 覆盖包可新增条目、空字符串视为有值参与覆盖。
    #[test]
    fn schema_documented_behaviors_hold() {
        let p = KnowledgePack::from_json(
            r#"{"schema":1,"id":"x","version":"1","language":"zh-CN","futureField":{"a":1},
                "stars":{"notAStar":{"intro":"未知键"},"ziweiMaj":{"intro":"已知键"}}}"#,
        )
        .unwrap();
        assert!(!p.to_json().contains("futureField"), "未知顶层字段应被丢弃");
        assert!(p.stars.contains_key("notAStar"), "未知条目键应被保留");
        assert_eq!(p.star_intro(StarKey::ZiweiMaj), Some("已知键"));
        let overlay = KnowledgePack::from_json(
            r#"{"schema":1,"id":"o","version":"1","language":"zh-CN",
                "stars":{"tianjiMaj":{"intro":"新增条目"},"ziweiMaj":{"intro":""}}}"#,
        )
        .unwrap();
        let m = p.merged(&[&overlay]);
        assert_eq!(
            m.star_intro(StarKey::TianjiMaj),
            Some("新增条目"),
            "覆盖包可新增条目"
        );
        assert_eq!(
            m.star_intro(StarKey::ZiweiMaj),
            Some(""),
            "空串视为有值并覆盖"
        );
        assert!(m.stars.contains_key("notAStar"), "未知键参与合并后仍保留");
    }

    #[test]
    fn newer_schema_is_rejected_and_bad_json_is_error() {
        assert!(KnowledgePack::from_json(r#"{"schema":99}"#).is_err());
        assert!(
            KnowledgePack::from_json(r#"{"id":"x"}"#).is_err(),
            "schema 缺失必须拒绝"
        );
        assert!(KnowledgePack::from_json("nope").is_err());
        let p =
            KnowledgePack::from_json(r#"{"schema":1,"id":"x","version":"1","language":"zh-CN"}"#)
                .unwrap();
        assert!(p.stars.is_empty());
        assert_eq!(KnowledgePack::from_json(&p.to_json()).unwrap(), p);
        // 映射类字段写成 null 等同缺省(Go 等语言默认序列化 nil map 即为 null)
        let n = KnowledgePack::from_json(
            r#"{"schema":1,"id":"x","version":"1","language":"zh-CN","source":null,
                "stars":{"ziweiMaj":{"attributes":null,"combinations":null}},"patterns":null,
                "palaces":null,"mutagens":null,"concepts":null}"#,
        )
        .unwrap();
        assert!(n.patterns.is_empty() && n.star(StarKey::ZiweiMaj).is_some());
    }
}