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