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}
184
185/// Typed provenance for stub entities. Set at stub creation and lives
186/// for the engine instance's
187/// lifetime. Boot reconstructs stubs via the parser and always tags
188/// them `LoadTime`; the `ForwardReference` and `Residual` variants
189/// therefore only appear during the engine lifetime in which they
190/// were created and reduce to `LoadTime` after a reload — this is
191/// intentional ("annotation, not state") and consistent with the
192/// "no tombstones" decision.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195pub enum StubKind {
196 /// Created by `memstead_relate` against an absent target; the
197 /// source entity declared the relation before the target
198 /// existed. Promotion via `memstead_create` clears the kind back
199 /// to `None` (stub adoption).
200 ForwardReference,
201 /// Auto-emitted at parse time from a wiki-link / Relationships
202 /// entry pointing at an entity not present in the current load.
203 /// The canonical post-reload variant for every stub.
204 LoadTime,
205 /// Left over by a `memstead_delete` or `memstead_rename` of a real
206 /// Write-Mem entity whose surviving incoming references all
207 /// live in ReadOnly mounts at the time. The in-memory stub
208 /// preserves those edges so `incoming(<old-id>)` stays
209 /// consistent with what a fresh boot from disk would produce.
210 /// `since_commit` records the commit that produced the demote;
211 /// `readonly_referrers` snapshots the surviving source ids at
212 /// mutation time (not live-updated).
213 Residual {
214 since_commit: String,
215 readonly_referrers: Vec<EntityId>,
216 },
217}
218
219/// A single H3–H6 heading span recorded under one H2 section. Byte offsets
220/// reference the raw section content (the string stored in
221/// `Entity.sections[key]`). Spans are stored flat — level skips (H2 → H4
222/// without H3) are tolerated and ancestry is resolved at query time via
223/// offset containment, not by inserting virtual intermediate spans.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct HeadingSpan {
226 /// Markdown heading level: 3, 4, 5, or 6.
227 pub level: u8,
228 /// Heading title (trimmed, no `#` prefix).
229 pub title: String,
230 /// Byte offset into the section content where this heading starts
231 /// (the `#` character position).
232 pub start_offset: usize,
233 /// Byte offset into the section content where this heading's scope ends
234 /// — either the start of the next heading with the same or lower level,
235 /// or the end of the section.
236 pub end_offset: usize,
237}
238
239/// A declared relationship in the Relationships section.
240///
241/// The optional `description` carries the per-edge text after the
242/// trailing em-dash on the markdown row (`- **TYPE**: [[X]] — text`).
243/// Validated against the rel-type's `per_edge_description` posture at
244/// mutation and parse time. Empty-string normalises to `None` so the
245/// renderer never emits a bare em-dash followed by nothing.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct Relationship {
248 pub rel_type: String,
249 pub target: EntityId,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub description: Option<String>,
252}
253
254impl Relationship {
255 /// Construct a relation with no per-edge description — the common
256 /// case for rel-types declared `forbidden` or `optional` (no text)
257 /// in their schema.
258 pub fn new(rel_type: impl Into<String>, target: EntityId) -> Self {
259 Self {
260 rel_type: rel_type.into(),
261 target,
262 description: None,
263 }
264 }
265}
266
267/// Normalise a per-edge description from the wire/argument surface:
268/// trims surrounding whitespace and collapses empty / whitespace-only
269/// inputs to `None`. Applied at every mutation entry-point so the
270/// renderer never emits a bare em-dash followed by zero characters and
271/// the posture-validation step sees a canonical input.
272pub fn normalise_description(description: Option<&str>) -> Option<String> {
273 description
274 .map(|s| s.trim().to_string())
275 .filter(|s| !s.is_empty())
276}
277
278/// Result of parsing a markdown file. Includes the entity, extracted inline
279/// links, and parse-time warnings (e.g. duplicate section headings).
280pub struct ParseResult {
281 pub entity: Entity,
282 /// Wiki-links found in text sections (become REFERENCES edges in the store).
283 pub inline_links: Vec<EntityId>,
284 /// Warnings raised during parsing — surface only at load / reload / attach
285 /// sites (the store builder pushes them into `LoadCollector::warnings`
286 /// when present). Validator and mutation sites consume `ParseResult`
287 /// without emitting these.
288 pub parse_warnings: Vec<crate::ops::WarningHint>,
289}
290
291/// Rewrite relationship and inline-link targets whose slug has a
292/// `"<mem>--<rest>"` shape where `<mem>` is a visible-writable mem
293/// name. Same-mem wiki-links (no `--` prefix, or a prefix that is not a
294/// known mem) are left unchanged — the resolver is additive and byte-
295/// identical for inputs without a known-mem prefix.
296///
297/// The parser is single-mem by construction: every produced target is
298/// `EntityId(current_mem, slug)`. This helper runs immediately after
299/// `parse_markdown` / `parse_file` at every parse-result consumer site so
300/// cross-mem references land in the store with the correct target mem.
301pub fn resolve_cross_mem_refs(
302 relationships: &mut [Relationship],
303 inline_links: &mut [EntityId],
304 current_mem: &str,
305 visible_writable: &HashSet<String>,
306) {
307 for rel in relationships.iter_mut() {
308 if let Some(resolved) = rewrite_target(&rel.target, current_mem, visible_writable) {
309 rel.target = resolved;
310 }
311 }
312 for link in inline_links.iter_mut() {
313 if let Some(resolved) = rewrite_target(link, current_mem, visible_writable) {
314 *link = resolved;
315 }
316 }
317}
318
319/// If `target`'s slug has a `"<prefix>--<rest>"` shape where `prefix` is a
320/// visible-writable mem name (and differs from `current_mem`), return
321/// the rewritten `EntityId(prefix, rest)`. Otherwise return `None`.
322///
323/// The split is on the **first** `--` so legacy slugs like
324/// `"some-legacy--slug"` stay same-mem whenever `"some-legacy"` is not a
325/// registered mem. Self-prefix (`prefix == current_mem`) is also a
326/// no-op — it would round-trip to the same `EntityId`, but skipping the
327/// rewrite avoids an unnecessary allocation.
328fn rewrite_target(
329 target: &EntityId,
330 current_mem: &str,
331 visible_writable: &HashSet<String>,
332) -> Option<EntityId> {
333 if target.mem() != current_mem {
334 // Already points at another mem — this can happen when the
335 // resolver is chained (idempotent) or when the target came from
336 // a non-parser path. Nothing to do.
337 return None;
338 }
339 let path = target.path();
340 let (prefix, rest) = path.split_once("--")?;
341 if prefix == current_mem {
342 return None;
343 }
344 if visible_writable.contains(prefix) {
345 Some(EntityId::new(prefix, rest))
346 } else {
347 None
348 }
349}
350
351#[cfg(test)]
352mod resolve_tests {
353 use super::*;
354
355 fn roster(names: &[&str]) -> HashSet<String> {
356 names.iter().map(|s| s.to_string()).collect()
357 }
358
359 fn rel(rel_type: &str, mem: &str, slug: &str) -> Relationship {
360 Relationship {
361 rel_type: rel_type.to_string(),
362 target: EntityId::new(mem, slug),
363 description: None,
364 }
365 }
366
367 #[test]
368 fn rewrites_cross_mem_relationship_when_prefix_is_known() {
369 let mut rels = vec![rel("USES", "plan", "main--foo")];
370 let mut inline: Vec<EntityId> = Vec::new();
371 resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
372 assert_eq!(rels[0].target.mem(), "main");
373 assert_eq!(rels[0].target.path(), "foo");
374 }
375
376 #[test]
377 fn leaves_relationship_unchanged_when_prefix_not_in_roster() {
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(&["plan"]));
381 assert_eq!(rels[0].target.mem(), "plan");
382 assert_eq!(rels[0].target.path(), "main--foo");
383 }
384
385 #[test]
386 fn target_without_double_dash_is_unchanged() {
387 let mut rels = vec![rel("USES", "plan", "foo")];
388 let mut inline: Vec<EntityId> = Vec::new();
389 resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
390 assert_eq!(rels[0].target.mem(), "plan");
391 assert_eq!(rels[0].target.path(), "foo");
392 }
393
394 #[test]
395 fn legacy_slug_with_unknown_prefix_stays_same_mem() {
396 let mut rels = vec![rel("USES", "plan", "some-legacy--slug")];
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(), "some-legacy--slug");
401 }
402
403 #[test]
404 fn rewrites_inline_links_identically_to_relationships() {
405 let mut rels = vec![rel("USES", "plan", "main--foo")];
406 let mut inline: Vec<EntityId> = vec![EntityId::new("plan", "main--bar")];
407 resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
408 assert_eq!(rels[0].target.mem(), "main");
409 assert_eq!(rels[0].target.path(), "foo");
410 assert_eq!(inline[0].mem(), "main");
411 assert_eq!(inline[0].path(), "bar");
412 }
413
414 #[test]
415 fn self_prefix_is_same_mem_noop() {
416 let mut rels = vec![rel("USES", "plan", "plan--foo")];
417 let mut inline: Vec<EntityId> = Vec::new();
418 resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
419 // "plan--foo" with current_mem="plan" is a same-mem slug
420 // containing `--`; the resolver leaves it alone so the entity
421 // id stays `plan--plan--foo` (which equals the input).
422 assert_eq!(rels[0].target.mem(), "plan");
423 assert_eq!(rels[0].target.path(), "plan--foo");
424 }
425
426 #[test]
427 fn split_on_first_double_dash() {
428 // `main--foo--bar` splits at the first `--`, so prefix=`main`
429 // and rest=`foo--bar` → `EntityId("main", "foo--bar")`.
430 let mut rels = vec![rel("USES", "plan", "main--foo--bar")];
431 let mut inline: Vec<EntityId> = Vec::new();
432 resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
433 assert_eq!(rels[0].target.mem(), "main");
434 assert_eq!(rels[0].target.path(), "foo--bar");
435 }
436
437 #[test]
438 fn target_already_in_another_mem_is_untouched() {
439 // A relationship whose target already points at another mem
440 // (e.g. carried through a chained-resolver path) is idempotent
441 // under a second call.
442 let mut rels = vec![rel("USES", "main", "foo")];
443 let mut inline: Vec<EntityId> = Vec::new();
444 resolve_cross_mem_refs(&mut rels, &mut inline, "plan", &roster(&["main", "plan"]));
445 assert_eq!(rels[0].target.mem(), "main");
446 assert_eq!(rels[0].target.path(), "foo");
447 }
448}