Skip to main content

memstead_base/entity/
mod.rs

1//! Entity types and the markdown pipeline (parse, generate, write,
2//! load).
3//!
4//! Backend-agnostic surface — `Directory` and `ZipArchive` sources
5//! live here in [`source`]; the gix-backed `GitTreeSource` lives in
6//! `memstead-git-branch::entity::git_tree_source` and shares the same parse
7//! pipeline via [`loader::parse_entries`].
8
9pub mod generator;
10pub mod id;
11pub mod loader;
12pub mod parser;
13pub mod source;
14pub mod store_builder;
15pub(crate) mod wikilink_rewrite;
16pub mod writer;
17
18use indexmap::IndexMap;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, HashSet};
22use std::fmt;
23
24/// Unique entity identifier: `mem--entity-path`.
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
26pub struct EntityId(pub String);
27
28impl EntityId {
29    /// Construct a mem-qualified entity id. Both inputs are NFC-
30    /// normalised before joining so the in-memory `HashMap<EntityId, ..>`
31    /// key matches across compose-form variants. Without this gate, a
32    /// title `"café"` (NFC) creates an entity whose id is the NFC byte
33    /// sequence; an NFD-form lookup (`"café"` with combining acute)
34    /// produces a byte-different `EntityId` and the store reports
35    /// `ENTITY_NOT_FOUND`. The write path already NFC-normalises the
36    /// title before slug derivation; this constructor closes the read
37    /// path. Mem names are constrained to ASCII (per the mem grammar
38    /// in [`crate::entity::id`]), so the NFC pass on the mem half is
39    /// structurally a no-op — applied uniformly for symmetry.
40    pub fn new(mem: &str, slug: &str) -> Self {
41        use unicode_normalization::UnicodeNormalization;
42        let mem_nfc: String = mem.nfc().collect();
43        let slug_nfc: String = slug.nfc().collect();
44        Self(format!("{mem_nfc}--{slug_nfc}"))
45    }
46
47    /// Construct from a full `mem--slug` id, NFC-normalising the
48    /// string. Use this at every read-path entry point that receives an
49    /// id string from outside the engine (MCP tool params, CLI argv,
50    /// file-path reconstruction). Direct `EntityId(s)` construction
51    /// bypasses normalisation and re-introduces the NFC/NFD lookup
52    /// hazard this constructor closes — prefer it.
53    pub fn canonical(id: &str) -> Self {
54        use unicode_normalization::UnicodeNormalization;
55        Self(id.nfc().collect())
56    }
57
58    /// Extract the mem part: `specs--my-entity` → `specs`.
59    pub fn mem(&self) -> &str {
60        match self.0.find("--") {
61            Some(idx) => &self.0[..idx],
62            None => "",
63        }
64    }
65
66    /// Extract the name part (last segment): `specs--parent/child` → `child`.
67    pub fn name(&self) -> &str {
68        let path = self.path();
69        match path.rfind('/') {
70            Some(i) => &path[i + 1..],
71            None => path,
72        }
73    }
74
75    /// Extract the full path after mem: `specs--parent/child` → `parent/child`.
76    pub fn path(&self) -> &str {
77        match self.0.find("--") {
78            Some(idx) => &self.0[idx + 2..],
79            None => &self.0,
80        }
81    }
82}
83
84impl fmt::Display for EntityId {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "{}", self.0)
87    }
88}
89
90impl AsRef<str> for EntityId {
91    fn as_ref(&self) -> &str {
92        &self.0
93    }
94}
95
96/// A metadata value with type coercion matching the JS parser behavior.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[serde(untagged)]
99pub enum MetadataValue {
100    Bool(bool),
101    Integer(i64),
102    Float(f64),
103    String(String),
104}
105
106impl MetadataValue {
107    /// Serialize to the string form used in YAML frontmatter.
108    pub fn to_frontmatter_string(&self) -> String {
109        match self {
110            Self::String(s) => s.clone(),
111            Self::Integer(n) => n.to_string(),
112            Self::Float(v) => format!("{v}"),
113            Self::Bool(b) => b.to_string(),
114        }
115    }
116
117    /// Get as a string reference (only for String variant).
118    pub fn as_str(&self) -> Option<&str> {
119        match self {
120            Self::String(s) => Some(s),
121            _ => None,
122        }
123    }
124
125    /// Check if the value is falsy (for omit-when-falsy serialization).
126    pub fn is_falsy(&self) -> bool {
127        match self {
128            Self::Bool(b) => !b,
129            Self::Integer(n) => *n == 0,
130            Self::Float(f) => *f == 0.0,
131            Self::String(s) => s.is_empty(),
132        }
133    }
134}
135
136impl fmt::Display for MetadataValue {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "{}", self.to_frontmatter_string())
139    }
140}
141
142/// A parsed entity with metadata, sections, and relationships.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Entity {
145    pub id: EntityId,
146    pub title: String,
147    pub entity_type: String,
148    pub mem: String,
149    pub file_path: String,
150    /// Frontmatter metadata. Ordered (IndexMap) so iteration preserves the
151    /// YAML key order the parser saw — required for deterministic rendering
152    /// by `render::render_entity_markdown`, which iterates this map directly.
153    pub metadata: IndexMap<String, MetadataValue>,
154    /// Section keys map to raw content. Ordered (IndexMap) so iteration yields
155    /// sections in the order the parser inserted them — today that matches the
156    /// schema's declared order, which is what renderers rely on for
157    /// deterministic output.
158    pub sections: IndexMap<String, String>,
159    pub relationships: Vec<Relationship>,
160    /// SHA-256 hash of the raw markdown content for optimistic locking.
161    pub content_hash: String,
162    /// True if this is a stub entity (created from an unresolved reference).
163    /// Tracks `stub_kind.is_some()` and is preserved for compatibility with
164    /// readers that don't need the typed provenance. New code branches on
165    /// [`Self::stub_kind`] when the origin matters (`ForwardReference` /
166    /// `LoadTime` / `Residual`).
167    pub stub: bool,
168    /// Typed stub provenance — set at stub creation and persists for the
169    /// engine instance's lifetime. `None` for real (non-stub) entities;
170    /// `Some(kind)` matches `stub == true` by construction (every
171    /// `make_stub` site sets both fields atomically). See [`StubKind`]
172    /// for the variant semantics and lifecycle rules.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub stub_kind: Option<StubKind>,
175    /// Per-H2-section H3–H6 heading spans — a derived parse artefact used by
176    /// search to attach `heading_path` to per-term matches. Keyed by H2
177    /// section key; value lists spans in document order with byte offsets
178    /// into the raw section content. Regenerated on every parse; never
179    /// written back to markdown, never hashed, never round-tripped. The
180    /// in-memory graph stays flat — sub-sections are search-only metadata.
181    #[serde(skip, default)]
182    pub heading_spans: HashMap<String, Vec<HeadingSpan>>,
183    /// Literal `## ` heading texts seen in the file, in document order —
184    /// a derived parse artefact used by health to distinguish "declared
185    /// section absent" from "content sits under a heading that does not
186    /// derive to the declared key" (the section-fork defect class).
187    /// Regenerated on every parse; never written back to markdown, never
188    /// hashed, never round-tripped. Empty on stubs and hand-built
189    /// entities.
190    #[serde(skip, default)]
191    pub raw_section_headings: Vec<String>,
192}
193
194/// Typed provenance for stub entities. Set at stub creation and lives
195/// for the engine instance's
196/// lifetime. Boot reconstructs stubs via the parser and always tags
197/// them `LoadTime`; the `ForwardReference` and `Residual` variants
198/// therefore only appear during the engine lifetime in which they
199/// were created and reduce to `LoadTime` after a reload — this is
200/// intentional ("annotation, not state") and consistent with the
201/// "no tombstones" decision.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum StubKind {
205    /// Created by `memstead_relate` against an absent target; the
206    /// source entity declared the relation before the target
207    /// existed. Promotion via `memstead_create` clears the kind back
208    /// to `None` (stub adoption).
209    ForwardReference,
210    /// Auto-emitted at parse time from a wiki-link / Relationships
211    /// entry pointing at an entity not present in the current load.
212    /// The canonical post-reload variant for every stub.
213    LoadTime,
214    /// Left over by a `memstead_delete` or `memstead_rename` of a real
215    /// Write-Mem entity whose surviving incoming references all
216    /// live in ReadOnly mounts at the time. The in-memory stub
217    /// preserves those edges so `incoming(<old-id>)` stays
218    /// consistent with what a fresh boot from disk would produce.
219    /// `since_commit` records the commit that produced the demote;
220    /// `readonly_referrers` snapshots the surviving source ids at
221    /// mutation time (not live-updated).
222    Residual {
223        since_commit: String,
224        readonly_referrers: Vec<EntityId>,
225    },
226}
227
228/// A single H3–H6 heading span recorded under one H2 section. Byte offsets
229/// reference the raw section content (the string stored in
230/// `Entity.sections[key]`). Spans are stored flat — level skips (H2 → H4
231/// without H3) are tolerated and ancestry is resolved at query time via
232/// offset containment, not by inserting virtual intermediate spans.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct HeadingSpan {
235    /// Markdown heading level: 3, 4, 5, or 6.
236    pub level: u8,
237    /// Heading title (trimmed, no `#` prefix).
238    pub title: String,
239    /// Byte offset into the section content where this heading starts
240    /// (the `#` character position).
241    pub start_offset: usize,
242    /// Byte offset into the section content where this heading's scope ends
243    /// — either the start of the next heading with the same or lower level,
244    /// or the end of the section.
245    pub end_offset: usize,
246}
247
248/// A declared relationship in the Relationships section.
249///
250/// The optional `description` carries the per-edge text after the
251/// trailing em-dash on the markdown row (`- **TYPE**: [[X]] — text`).
252/// Validated against the rel-type's `per_edge_description` posture at
253/// mutation and parse time. Empty-string normalises to `None` so the
254/// renderer never emits a bare em-dash followed by nothing.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Relationship {
257    pub rel_type: String,
258    pub target: EntityId,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub description: Option<String>,
261}
262
263impl Relationship {
264    /// Construct a relation with no per-edge description — the common
265    /// case for rel-types declared `forbidden` or `optional` (no text)
266    /// in their schema.
267    pub fn new(rel_type: impl Into<String>, target: EntityId) -> Self {
268        Self {
269            rel_type: rel_type.into(),
270            target,
271            description: None,
272        }
273    }
274}
275
276/// Normalise a per-edge description from the wire/argument surface:
277/// trims surrounding whitespace and collapses empty / whitespace-only
278/// inputs to `None`. Applied at every mutation entry-point so the
279/// renderer never emits a bare em-dash followed by zero characters and
280/// the posture-validation step sees a canonical input.
281pub fn normalise_description(description: Option<&str>) -> Option<String> {
282    description
283        .map(|s| s.trim().to_string())
284        .filter(|s| !s.is_empty())
285}
286
287/// Result of parsing a markdown file. Includes the entity, extracted inline
288/// links, and parse-time warnings (e.g. duplicate section headings).
289pub struct ParseResult {
290    pub entity: Entity,
291    /// Wiki-links found in text sections (become REFERENCES edges in the store).
292    pub inline_links: Vec<EntityId>,
293    /// Warnings raised during parsing — surface only at load / reload / attach
294    /// sites (the store builder pushes them into `LoadCollector::warnings`
295    /// when present). Validator and mutation sites consume `ParseResult`
296    /// without emitting these.
297    pub parse_warnings: Vec<crate::ops::WarningHint>,
298}
299
300/// Rewrite relationship and inline-link targets whose slug has a
301/// `"<mem>--<rest>"` shape where `<mem>` is a visible-writable mem
302/// name. Same-mem wiki-links (no `--` prefix, or a prefix that is not a
303/// known mem) are left unchanged — the resolver is additive and byte-
304/// identical for inputs without a known-mem prefix.
305///
306/// The parser is single-mem by construction: every produced target is
307/// `EntityId(current_mem, slug)`. This helper runs immediately after
308/// `parse_markdown` / `parse_file` at every parse-result consumer site so
309/// cross-mem references land in the store with the correct target mem.
310pub fn resolve_cross_mem_refs(
311    relationships: &mut [Relationship],
312    inline_links: &mut [EntityId],
313    current_mem: &str,
314    visible_writable: &HashSet<String>,
315) {
316    for rel in relationships.iter_mut() {
317        if let Some(resolved) = rewrite_target(&rel.target, current_mem, visible_writable) {
318            rel.target = resolved;
319        }
320    }
321    for link in inline_links.iter_mut() {
322        if let Some(resolved) = rewrite_target(link, current_mem, visible_writable) {
323            *link = resolved;
324        }
325    }
326}
327
328/// If `target`'s slug has a `"<prefix>--<rest>"` shape where `prefix` is a
329/// visible-writable mem name (and differs from `current_mem`), return
330/// the rewritten `EntityId(prefix, rest)`. Otherwise return `None`.
331///
332/// The split is on the **first** `--` so legacy slugs like
333/// `"some-legacy--slug"` stay same-mem whenever `"some-legacy"` is not a
334/// registered mem. Self-prefix (`prefix == current_mem`) is also a
335/// no-op — it would round-trip to the same `EntityId`, but skipping the
336/// rewrite avoids an unnecessary allocation.
337fn rewrite_target(
338    target: &EntityId,
339    current_mem: &str,
340    visible_writable: &HashSet<String>,
341) -> Option<EntityId> {
342    if target.mem() != current_mem {
343        // Already points at another mem — this can happen when the
344        // resolver is chained (idempotent) or when the target came from
345        // a non-parser path. Nothing to do.
346        return None;
347    }
348    let path = target.path();
349    let (prefix, rest) = path.split_once("--")?;
350    if prefix == current_mem {
351        return None;
352    }
353    if visible_writable.contains(prefix) {
354        Some(EntityId::new(prefix, rest))
355    } else {
356        None
357    }
358}
359
360#[cfg(test)]
361mod resolve_tests {
362    use super::*;
363
364    fn roster(names: &[&str]) -> HashSet<String> {
365        names.iter().map(|s| s.to_string()).collect()
366    }
367
368    fn rel(rel_type: &str, mem: &str, slug: &str) -> Relationship {
369        Relationship {
370            rel_type: rel_type.to_string(),
371            target: EntityId::new(mem, slug),
372            description: None,
373        }
374    }
375
376    #[test]
377    fn rewrites_cross_mem_relationship_when_prefix_is_known() {
378        let mut rels = vec![rel("USES", "plan", "main--foo")];
379        let mut inline: Vec<EntityId> = Vec::new();
380        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
381        assert_eq!(rels[0].target.mem(), "main");
382        assert_eq!(rels[0].target.path(), "foo");
383    }
384
385    #[test]
386    fn leaves_relationship_unchanged_when_prefix_not_in_roster() {
387        let mut rels = vec![rel("USES", "plan", "main--foo")];
388        let mut inline: Vec<EntityId> = Vec::new();
389        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["plan"]));
390        assert_eq!(rels[0].target.mem(), "plan");
391        assert_eq!(rels[0].target.path(), "main--foo");
392    }
393
394    #[test]
395    fn target_without_double_dash_is_unchanged() {
396        let mut rels = vec![rel("USES", "plan", "foo")];
397        let mut inline: Vec<EntityId> = Vec::new();
398        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
399        assert_eq!(rels[0].target.mem(), "plan");
400        assert_eq!(rels[0].target.path(), "foo");
401    }
402
403    #[test]
404    fn legacy_slug_with_unknown_prefix_stays_same_mem() {
405        let mut rels = vec![rel("USES", "plan", "some-legacy--slug")];
406        let mut inline: Vec<EntityId> = Vec::new();
407        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
408        assert_eq!(rels[0].target.mem(), "plan");
409        assert_eq!(rels[0].target.path(), "some-legacy--slug");
410    }
411
412    #[test]
413    fn rewrites_inline_links_identically_to_relationships() {
414        let mut rels = vec![rel("USES", "plan", "main--foo")];
415        let mut inline: Vec<EntityId> = vec![EntityId::new("plan", "main--bar")];
416        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
417        assert_eq!(rels[0].target.mem(), "main");
418        assert_eq!(rels[0].target.path(), "foo");
419        assert_eq!(inline[0].mem(), "main");
420        assert_eq!(inline[0].path(), "bar");
421    }
422
423    #[test]
424    fn self_prefix_is_same_mem_noop() {
425        let mut rels = vec![rel("USES", "plan", "plan--foo")];
426        let mut inline: Vec<EntityId> = Vec::new();
427        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
428        // "plan--foo" with current_mem="plan" is a same-mem slug
429        // containing `--`; the resolver leaves it alone so the entity
430        // id stays `plan--plan--foo` (which equals the input).
431        assert_eq!(rels[0].target.mem(), "plan");
432        assert_eq!(rels[0].target.path(), "plan--foo");
433    }
434
435    #[test]
436    fn split_on_first_double_dash() {
437        // `main--foo--bar` splits at the first `--`, so prefix=`main`
438        // and rest=`foo--bar` → `EntityId("main", "foo--bar")`.
439        let mut rels = vec![rel("USES", "plan", "main--foo--bar")];
440        let mut inline: Vec<EntityId> = Vec::new();
441        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
442        assert_eq!(rels[0].target.mem(), "main");
443        assert_eq!(rels[0].target.path(), "foo--bar");
444    }
445
446    #[test]
447    fn target_already_in_another_mem_is_untouched() {
448        // A relationship whose target already points at another mem
449        // (e.g. carried through a chained-resolver path) is idempotent
450        // under a second call.
451        let mut rels = vec![rel("USES", "main", "foo")];
452        let mut inline: Vec<EntityId> = Vec::new();
453        resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
454        assert_eq!(rels[0].target.mem(), "main");
455        assert_eq!(rels[0].target.path(), "foo");
456    }
457}