Skip to main content

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}