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}