stoa_core/frontmatter.rs
1//! YAML frontmatter — the in-memory shape of a wiki page's metadata block.
2//!
3//! Required fields (per ARCHITECTURE §2):
4//!
5//! ```yaml
6//! id: ent-redis
7//! kind: entity # entity | concept | synthesis
8//! title: Redis
9//! created: 2026-05-12T14:32:00Z
10//! updated: 2026-05-12T18:01:00Z
11//! status: active # active | superseded | stale | deprecated
12//! ```
13//!
14//! Kind-specific extras land in the [`KindData`] variant. Entities **must**
15//! supply `type:`; concepts and synthesis pages have optional extras.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::kind::{Kind, Status};
21use crate::relationship::{EntityType, Relationship};
22
23/// Parsed frontmatter block. Round-trips through `serde_yaml` (asserted by
24/// the proptest `frontmatter_roundtrips` in `tests/frontmatter_roundtrip.rs`).
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Frontmatter {
27 /// Stable page identifier (e.g. `ent-redis`, `con-rag`, `syn-…`).
28 pub id: String,
29 /// Human-readable title.
30 pub title: String,
31 /// Lifecycle status.
32 pub status: Status,
33 /// RFC-3339 / ISO-8601 creation timestamp.
34 pub created: DateTime<Utc>,
35 /// RFC-3339 / ISO-8601 last-update timestamp.
36 pub updated: DateTime<Utc>,
37
38 /// Kind-discriminated extras (the `kind:` field plus per-kind data).
39 ///
40 /// Flattened so the YAML stays flat — `kind: entity` sits alongside
41 /// `id:` and `title:`, not nested.
42 #[serde(flatten)]
43 pub kind_data: KindData,
44}
45
46/// Kind-specific frontmatter fields. The `kind:` discriminator lives on this
47/// enum so an entity is forced to carry `type:` and a synthesis is forced
48/// to carry `inputs:` / `question:` (when present).
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(tag = "kind", rename_all = "lowercase")]
51pub enum KindData {
52 /// `kind: entity` — required `type:`, optional aliases + relationships.
53 Entity(EntityData),
54 /// `kind: concept` — optional relationships.
55 Concept(ConceptData),
56 /// `kind: synthesis` — optional `inputs:` / `question:`.
57 Synthesis(SynthesisData),
58}
59
60/// Entity-specific frontmatter (ARCHITECTURE §2 — entity block).
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct EntityData {
63 /// Entity sub-type — validated against the schema vocabulary.
64 #[serde(rename = "type")]
65 pub entity_type: EntityType,
66 /// Optional alternate names / abbreviations.
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub aliases: Vec<String>,
69 /// Optional typed relationships out from this entity.
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub relationships: Vec<Relationship>,
72}
73
74/// Concept-specific frontmatter (ARCHITECTURE §2 — concept block).
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct ConceptData {
77 /// Optional typed relationships.
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub relationships: Vec<Relationship>,
80}
81
82/// Synthesis-specific frontmatter (ARCHITECTURE §2 — synthesis block).
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct SynthesisData {
85 /// Source pages + raw artifacts that fed this synthesis.
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub inputs: Vec<String>,
88 /// The question the synthesis answers, if any.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub question: Option<String>,
91}
92
93impl Frontmatter {
94 /// Convenience accessor for `kind:` as the canonical lowercase string.
95 #[must_use]
96 pub fn kind(&self) -> Kind {
97 match &self.kind_data {
98 KindData::Entity(_) => Kind::Entity,
99 KindData::Concept(_) => Kind::Concept,
100 KindData::Synthesis(_) => Kind::Synthesis,
101 }
102 }
103}