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}