memstead_base/entity/id.rs
1//! Entity ID parsing, generation, and path mapping.
2
3use super::EntityId;
4use unicode_normalization::UnicodeNormalization;
5
6/// Cap on the full `mem--slug` entity id (Unicode scalar length).
7/// 200 leaves headroom for `mem--`-style prefixes and the `.md`
8/// suffix against the 255-byte `NAME_MAX` ceiling on common
9/// filesystems. The read-path validator on the MCP surface and the
10/// write-path slug derivation share this constant so an entity that
11/// the write side accepts is always readable on the same wire.
12/// F2 + F4.
13pub const ENTITY_ID_MAX_LEN: usize = 200;
14
15/// Error cases for title→slug derivation. `title_to_slug` itself is
16/// total — any title produces a slug (residual cases like
17/// all-emoji collapse to a deterministic short-hash id) — so it never
18/// returns these variants directly. The strict mutation-entry gate
19/// [`validate_and_derive_slug`] returns them for control characters
20/// and for input that would have fallen back to the hash slug;
21/// [`enforce_id_length`] returns [`Self::IdTooLong`] when the
22/// derived `mem--slug` exceeds the read-path length cap.
23///
24/// Loader and parse paths continue to call [`title_to_slug`] so
25/// pre-gate entities created with the old permissive pipeline remain
26/// readable.
27#[derive(Debug, thiserror::Error)]
28pub enum SlugError {
29 /// The derived `mem--slug` id exceeds [`ENTITY_ID_MAX_LEN`]. The
30 /// read-path validator rejects ids past this length, so without
31 /// this guard a title that the write path accepts produces an
32 /// entity that is silently unreachable on read.
33 ///
34 /// The bound is on the **composed id** (`<mem>--<slug>`), which is
35 /// also the on-disk filename — so the budget is mem-name-dependent
36 /// and the same title can be valid in a short-named mem and rejected
37 /// in a longer-named one. `input` echoes that composed id (not the
38 /// title) so it agrees with `length`: the payload measures one
39 /// quantity, the id, end to end. `max` is [`ENTITY_ID_MAX_LEN`] so the
40 /// agent can shorten by the exact delta. F2 + F4.
41 #[error(
42 "entity id \"{input}\" is {length} characters (max {max}); the id is `<mem>--<slug>`, so the title budget shrinks as the mem name grows — shorten the title"
43 )]
44 IdTooLong {
45 /// The composed `<mem>--<slug>` id whose length exceeded the
46 /// cap. Echoed as the `input` wire field so `input` and `length`
47 /// describe the same measured quantity.
48 input: String,
49 length: usize,
50 max: usize,
51 },
52 /// Strict mutation-entry rejection: the title is empty,
53 /// whitespace-only, or composed exclusively of pipeline-separator
54 /// characters (hyphens) so the slug pipeline would have collapsed
55 /// it to a hash-fallback id. Recovery: supply a non-empty title
56 /// with at least one alphanumeric character. F4.
57 #[error("title is empty or contains no slug-meaningful characters")]
58 TitleEmpty { input: String },
59 /// Strict mutation-entry rejection: the title contains control
60 /// characters (newline, tab, other C0/C1 controls). These are
61 /// Unicode whitespace, so the slug pipeline silently folds them to
62 /// hyphens and accepts the title — but they survive verbatim into the
63 /// stored `# H1` heading, which then splits across lines so every
64 /// read truncates the title at the first control char (search and
65 /// `memstead_entity` see only the prefix). Refused up front with the same
66 /// named-offenders + `proposed_slug` recovery shape the invalid-char
67 /// guard uses. `control_chars` lists each distinct offender in source
68 /// order; `proposed_slug` is the slug the pipeline would produce, for
69 /// a mechanical retry with a single-line title. F8.
70 #[error(
71 "title {input:?} contains control character(s) {control_chars:?} that would split the stored heading — \
72 retry with a single-line title (proposed slug: \"{proposed_slug}\")"
73 )]
74 TitleHasControlChars {
75 input: String,
76 control_chars: Vec<char>,
77 proposed_slug: String,
78 },
79}
80
81impl SlugError {
82 /// Stable discriminator for the structured-details `reason` field
83 /// on the `INVALID_TITLE` wire envelope. Each surface (MCP, CLI)
84 /// reads this when building the response payload.
85 pub fn reason(&self) -> &'static str {
86 match self {
87 SlugError::IdTooLong { .. } => "id_too_long",
88 SlugError::TitleEmpty { .. } => "empty",
89 SlugError::TitleHasControlChars { .. } => "control_chars",
90 }
91 }
92}
93
94/// The separator between mem and entity path in IDs.
95/// Build an EntityId from mem and title.
96pub fn build_id(mem: &str, title: &str) -> Result<EntityId, SlugError> {
97 let slug = title_to_slug(title)?;
98 let id = EntityId::new(mem, &slug);
99 enforce_id_length(id.as_ref())?;
100 Ok(id)
101}
102
103/// Reject ids whose Unicode scalar length exceeds
104/// [`ENTITY_ID_MAX_LEN`]. Shared by [`build_id`] and the engine's
105/// `create_entity` / `rename_entity` paths so the write side never
106/// produces an id the read side would refuse. The cap is on the
107/// composed `<mem>--<slug>` id (which is also the filename), so the
108/// error echoes the id itself — the `reason`, the echoed `input`, and
109/// the reported `length` all describe the id, not the title. F2 + F4.
110pub fn enforce_id_length(id: &str) -> Result<(), SlugError> {
111 if id.chars().count() > ENTITY_ID_MAX_LEN {
112 return Err(SlugError::IdTooLong {
113 input: id.to_string(),
114 length: id.chars().count(),
115 max: ENTITY_ID_MAX_LEN,
116 });
117 }
118 Ok(())
119}
120
121/// Convert a title string to a kebab-case slug.
122///
123/// Pipeline (F1, option B+A):
124///
125/// 1. **NFC-normalize** so combining sequences fold into precomposed
126/// forms (`Café` written NFD becomes `Café` written NFC). One
127/// canonical surface form keeps slug equality byte-stable across
128/// NFD-storing filesystems (older HFS+) and NFC-default ones
129/// (APFS, ext4, NTFS).
130/// 2. **Lowercase** via Unicode default case-folding — correct for
131/// Latin / Cyrillic / Greek / Armenian; no-op for case-less
132/// scripts (CJK, Arabic, Hebrew, Devanagari, Thai, etc.).
133/// 3. **Whitespace → hyphen**.
134/// 4. **Filter to `is_alphanumeric() || '-'`** — Unicode alphanumeric,
135/// not ASCII. Keeps every Latin and non-Latin letter or digit;
136/// drops combining marks, punctuation, symbols, emoji, and the
137/// reserved `--` / `:` separators by construction.
138/// 5. **Collapse hyphen runs, trim**.
139///
140/// Always returns `Ok(...)`. When the filter leaves the slug empty
141/// (all-emoji titles, all-punctuation, all-symbol titles), the slug
142/// degrades to a deterministic short hash of the title
143/// (`entity-<8-hex>`) rather than failing. Titles that are already
144/// slug-form — case-less scripts (`知識グラフ`) and lowercase
145/// single-token Latin (`wohnung`) — produce slug == title, so
146/// Obsidian-style `[[<title>]]` authoring round-trips without lookup
147/// for exactly those titles; any other title (a capital, a space:
148/// `Knowledge Graph`) derives a different slug, and the strict
149/// wiki-link decoder below refuses the natural form as a link target
150/// — such entities are linked by slug (`[[knowledge-graph]]`).
151pub fn title_to_slug(title: &str) -> Result<String, SlugError> {
152 let normalized: String = title.nfc().collect();
153 let slug: String = normalized
154 .chars()
155 .flat_map(|c| c.to_lowercase())
156 .map(|c| if c.is_whitespace() { '-' } else { c })
157 .filter(|c| c.is_alphanumeric() || *c == '-')
158 .collect::<String>()
159 .split('-')
160 .filter(|s| !s.is_empty())
161 .collect::<Vec<_>>()
162 .join("-");
163 if slug.is_empty() {
164 return Ok(format!("entity-{}", short_hash(title)));
165 }
166 Ok(slug)
167}
168
169/// The accepted title grammar, stated as a rule. THE single sentence
170/// every surface that documents titles carries: the CLI's
171/// create/rename help embeds it at build time, the MCP
172/// `memstead_create` / `memstead_rename` descriptions contain it
173/// verbatim (a tool-surface test asserts the containment), and the
174/// handbook quotes it naming this constant as its source. A
175/// conformance test in this module asserts
176/// [`validate_and_derive_slug`]'s behaviour matches the sentence's
177/// claim — so neither the prose nor the validator can drift alone.
178///
179/// The sentence's two claims, executable:
180///
181/// ```
182/// use memstead_base::entity::id::validate_and_derive_slug;
183///
184/// // "control characters such as tab/newline are rejected"
185/// assert!(validate_and_derive_slug("two\nlines").is_err());
186///
187/// // "characters outside Unicode alphanumerics, whitespace, and
188/// // hyphen are dropped from the derived slug" — and reported.
189/// let d = validate_and_derive_slug("The Gate's Rule — v2").unwrap();
190/// assert_eq!(d.slug, "the-gates-rule-v2");
191/// assert_eq!(d.dropped_chars, vec!['\'', '—']);
192/// ```
193pub const TITLE_GRAMMAR_RULE: &str = "Titles accept any single-line text (control characters such as tab/newline are rejected); the title is stored verbatim as display text, while characters outside Unicode alphanumerics, whitespace, and hyphen are dropped from the derived slug — warning TITLE_CHARS_DROPPED_FROM_SLUG names them";
194
195/// A strict-gate derivation result: the slug plus the distinct title
196/// characters (source order, post NFC + case-fold) the pipeline
197/// dropped on the way. `dropped_chars` non-empty means the title and
198/// its id diverge beyond case/whitespace — the mutation surfaces it
199/// as the typed `TITLE_CHARS_DROPPED_FROM_SLUG` warning so the
200/// divergence stays visible without being fatal.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct SlugDerivation {
203 pub slug: String,
204 pub dropped_chars: Vec<char>,
205}
206
207/// Strict slug derivation for mutation entry (`memstead_create`,
208/// `memstead_rename`). Runs the same pipeline as [`title_to_slug`] —
209/// byte-identical slugs for every title — but refuses the residual
210/// cases the permissive variant tolerates:
211///
212/// 1. **Control characters.** They would survive verbatim into the
213/// stored `# H1` and split it across lines. Returns
214/// [`SlugError::TitleHasControlChars`].
215/// 2. **Empty / collapses-to-empty.** Empty input, whitespace-only,
216/// hyphen-only, or all-dropped input — anything that would force
217/// the loader-path hash fallback. Returns [`SlugError::TitleEmpty`].
218///
219/// Any other character is admitted: the title is display text, stored
220/// verbatim, and characters outside the slug alphabet are dropped from
221/// the derived slug and reported in
222/// [`SlugDerivation::dropped_chars`] ([`TITLE_GRAMMAR_RULE`]).
223///
224/// Loader paths continue to call [`title_to_slug`] so pre-gate
225/// entities created with the old permissive pipeline remain
226/// readable — only mutation entry runs this strict gate.
227///
228/// ```
229/// use memstead_base::entity::id::{SlugError, validate_and_derive_slug};
230///
231/// // The happy path: Unicode-aware kebab-case, divergence reported.
232/// let d = validate_and_derive_slug("Knowledge Graph!").unwrap();
233/// assert_eq!(d.slug, "knowledge-graph");
234/// assert_eq!(d.dropped_chars, vec!['!']);
235///
236/// // Refusal 1: control characters.
237/// assert!(matches!(
238/// validate_and_derive_slug("tab\there"),
239/// Err(SlugError::TitleHasControlChars { .. })
240/// ));
241///
242/// // Refusal 2: a title whose slug collapses to empty.
243/// assert!(matches!(
244/// validate_and_derive_slug("!!!"),
245/// Err(SlugError::TitleEmpty { .. })
246/// ));
247/// ```
248pub fn validate_and_derive_slug(title: &str) -> Result<SlugDerivation, SlugError> {
249 let normalized: String = title.nfc().collect();
250 let case_folded: String = normalized.chars().flat_map(|c| c.to_lowercase()).collect();
251
252 // Control characters (newline, tab, other C0/C1) are Unicode
253 // whitespace, so the slug pipeline below would fold them to hyphens
254 // and accept the title — but they survive into the stored `# H1`,
255 // splitting it across lines and truncating every read of the title.
256 // Refuse them before the slug derivation.
257 let mut control_chars: Vec<char> = Vec::new();
258 for c in case_folded.chars() {
259 if c.is_control() && !control_chars.contains(&c) {
260 control_chars.push(c);
261 }
262 }
263 if !control_chars.is_empty() {
264 let proposed = title_to_slug(title).unwrap_or_default();
265 return Err(SlugError::TitleHasControlChars {
266 input: title.to_string(),
267 control_chars,
268 proposed_slug: proposed,
269 });
270 }
271
272 // Characters outside the slug alphabet are dropped from the id —
273 // recorded, not refused: the title is display text, the slug is
274 // the sanitised identifier, and the divergence rides back to the
275 // caller as a typed warning.
276 let mut dropped_chars: Vec<char> = Vec::new();
277 for c in case_folded.chars() {
278 if c.is_whitespace() || c == '-' || c.is_alphanumeric() {
279 continue;
280 }
281 if !dropped_chars.contains(&c) {
282 dropped_chars.push(c);
283 }
284 }
285
286 let slug: String = case_folded
287 .chars()
288 .filter(|c| c.is_whitespace() || *c == '-' || c.is_alphanumeric())
289 .map(|c| if c.is_whitespace() { '-' } else { c })
290 .collect::<String>()
291 .split('-')
292 .filter(|s| !s.is_empty())
293 .collect::<Vec<_>>()
294 .join("-");
295
296 if slug.is_empty() {
297 return Err(SlugError::TitleEmpty {
298 input: title.to_string(),
299 });
300 }
301
302 Ok(SlugDerivation {
303 slug,
304 dropped_chars,
305 })
306}
307
308/// Deterministic 8-char hex digest used as the fallback slug when
309/// the title contains no Unicode alphanumeric characters (the
310/// residual case of [`title_to_slug`]'s pipeline). 32 bits is
311/// plenty for collision-resistance inside a single mem; the
312/// fallback only fires for titles that contain no
313/// agent-meaningful characters anyway, so the opaque form is
314/// acceptable. F1 (option A backstop).
315fn short_hash(input: &str) -> String {
316 use sha2::{Digest, Sha256};
317 let digest = Sha256::digest(input.as_bytes());
318 format!(
319 "{:02x}{:02x}{:02x}{:02x}",
320 digest[0], digest[1], digest[2], digest[3]
321 )
322}
323
324/// Convert a relative file path to a mem-prefixed entity ID.
325///
326/// `file_path_to_id("architecture/result.md", "specs")` → `specs--architecture/result`
327pub fn file_path_to_id(path: &str, mem: &str) -> EntityId {
328 let stripped = path.strip_suffix(".md").unwrap_or(path);
329 EntityId::new(mem, stripped)
330}
331
332/// Strict wiki-link grammar refusal. Returned by [`wiki_link_to_id`]
333/// when the input between `[[...]]` (after alias / `.md` strip) does
334/// not resolve to a slug-form `EntityId`. Two variants matching the
335/// two grammars a wiki-link target carries:
336///
337/// - [`Self::InvalidMemName`] — Tier-2 prefix `[[mem:slug]]`'s
338/// mem name fails [`validate_mem_name_grammar`]. Recovery is
339/// manual: mem names are fixed identifiers in the workspace, not
340/// free-form text the agent can slugify.
341/// - [`Self::InvalidTarget`] — the slug-form path fails
342/// [`validate_id_path_grammar`]. Carries the
343/// [`title_to_slug`]-derived suggestion (omitted when the input
344/// has no meaningful slug equivalent — empty, all-punctuation,
345/// all-emoji).
346#[derive(Debug, thiserror::Error, Clone)]
347pub enum WikiLinkError {
348 #[error("mem prefix '{raw}' is not a valid mem name: {reason}")]
349 InvalidMemName { raw: String, reason: String },
350 #[error("wiki-link target '{raw}' is not slug-form: {reason}")]
351 InvalidTarget {
352 raw: String,
353 suggested: Option<String>,
354 reason: String,
355 },
356}
357
358/// Compute the [`title_to_slug`]-derived suggestion for a malformed
359/// wiki-link target. Returns `None` when the slug pipeline produces
360/// either an empty result or the deterministic hash fallback
361/// (`entity-<8hex>`) — both signal that the input has no canonical
362/// form the agent can mechanically lift into a retry.
363fn wiki_link_suggestion(raw: &str) -> Option<String> {
364 let derived = title_to_slug(raw).ok()?;
365 if derived.is_empty() || derived.starts_with("entity-") {
366 return None;
367 }
368 validate_id_path_grammar(&derived)
369 .is_ok()
370 .then_some(derived)
371}
372
373/// Convert a wiki-link target to a mem-prefixed entity ID, refusing
374/// non-slug-form inputs.
375///
376/// Recognises three grammars:
377/// - **Tier 0** `[[<mem>--<slug>]]` — cross-mem dash-form,
378/// symmetric with every engine-emitted ID: body wiki-links accept
379/// the canonical `<mem>--<slug>` form the engine writes elsewhere
380/// so an agent can author the same grammar in both directions.
381/// `<mem>` must match the single-segment mem-name grammar
382/// (`[a-z0-9-]+`, no `/`); hierarchical mem names stay on the
383/// Tier-2 colon-form. Cross-mem routing is policy-gated downstream in
384/// the alias-synthesis pass (same code path that already gates
385/// body-link → REFERENCES emission).
386/// - **Tier 1** `[[slug]]` or `[[a/b/c]]` — same-mem, resolves to
387/// `<current_mem>--<slug>`.
388/// - **Tier 2** `[[leaf:slug]]` — cross-mem, same mem-repo, resolves
389/// to `<leaf>--<slug>`. Hierarchical paths are first-class: the
390/// prefix accepts the full `team/sub-mem` form, so
391/// `[[team/sub-mem:auth-service]]` resolves to
392/// `team/sub-mem--auth-service`. Tier-1 with a
393/// hierarchical-mem dash-prefix (`[[team/sub-mem--auth-service]]`)
394/// remains unsupported — that combination is genuinely ambiguous
395/// between a cross-mem reference into a hierarchical mem and a
396/// same-mem entity at a hierarchical slug. Operators authoring
397/// such references must use the colon Tier-2 form.
398///
399/// Strips `[[` / `]]`, Obsidian alias (`|display`), `../` prefixes, `.md`
400/// suffix, and a redundant leading `<current_mem>--` (so an agent that
401/// writes the canonical fully-qualified id `[[mem--slug]]` produces the
402/// same `EntityId` as the bare-slug form `[[slug]]` instead of doubly-
403/// prefixing into `mem--mem--slug`).
404///
405/// Strictness: any input whose Tier-2 prefix fails
406/// [`validate_mem_name_grammar`] or whose resolved slug fails
407/// [`validate_id_path_grammar`] refuses with [`WikiLinkError`]. There is
408/// no permissive form that constructs an `EntityId` from any character
409/// sequence between the brackets — callers
410/// (`extract_inline_links`, the relate path's body scanners)
411/// propagate the refusal so an agent's `[[Knowledge Graph]]` body
412/// link can no longer land a malformed auto-stub. Read-side scanners
413/// that must tolerate pre-strict on-disk drift use
414/// [`wiki_link_to_id_lenient`].
415///
416/// Hierarchical-dash ambiguity: the Tier-1 fallback refuses inputs whose post-
417/// self-prefix-strip slug contains BOTH `/` and `--`
418/// (`[[team/sub-mem--target]]`). The combination is grammatically
419/// ambiguous between a cross-mem reference into a hierarchical mem
420/// and a same-mem entity at a hierarchical slug; the refusal carries
421/// the canonical colon form (`team/sub-mem:target`) as `suggested`.
422pub fn wiki_link_to_id(link: &str, current_mem: &str) -> Result<EntityId, WikiLinkError> {
423 let stripped = strip_wiki_link_decorations(link);
424
425 if !stripped.contains("::")
426 && let Some(colon_idx) = stripped.find(':')
427 {
428 let (prefix, rest) = stripped.split_at(colon_idx);
429 let slug_part = &rest[1..];
430 if !prefix.is_empty() && !slug_part.is_empty() {
431 if let Err(reason) = validate_mem_name_grammar(prefix) {
432 return Err(WikiLinkError::InvalidMemName {
433 raw: prefix.to_string(),
434 reason,
435 });
436 }
437 if let Err(reason) = validate_id_path_grammar(slug_part) {
438 let suggested = wiki_link_suggestion(slug_part).map(|s| format!("{prefix}:{s}"));
439 return Err(WikiLinkError::InvalidTarget {
440 raw: stripped.to_string(),
441 suggested,
442 reason,
443 });
444 }
445 return Ok(EntityId::new(prefix, slug_part));
446 }
447 }
448
449 // Tier 0 — cross-mem dash form `<mem>--<slug>`. Symmetric
450 // with every engine-emitted ID. Recognises only
451 // single-segment mem names (no `/` in the prefix); the
452 // hierarchical-mem dash form is grammatically ambiguous (see
453 // the dash/slash refusal further down) and stays on the colon
454 // Tier-2 form. Routes to the named mem even when it differs
455 // from `current_mem` — the cross-mem policy gate fires in
456 // the alias-synthesis pass, not here.
457 if let Some(dash_idx) = stripped.find("--") {
458 let prefix = &stripped[..dash_idx];
459 let suffix = &stripped[dash_idx + 2..];
460 if !prefix.is_empty()
461 && !suffix.is_empty()
462 && !prefix.contains('/')
463 && validate_mem_name_grammar(prefix).is_ok()
464 && validate_id_path_grammar(suffix).is_ok()
465 {
466 return Ok(EntityId::new(prefix, suffix));
467 }
468 }
469
470 let slug = if !current_mem.is_empty() {
471 let self_prefix = format!("{current_mem}--");
472 stripped
473 .strip_prefix(self_prefix.as_str())
474 .unwrap_or(&stripped)
475 } else {
476 &stripped
477 };
478 // A slug carrying BOTH `/` and `--` is grammatically ambiguous —
479 // it could be a cross-mem reference into a hierarchical mem
480 // (`team/sub-mem--target` → mem `team/sub-mem`, slug `target`)
481 // or a same-mem entity at a hierarchical slug that happens to
482 // contain `--`. The docstring above pins the canonical disambiguation
483 // (colon-form for cross-mem) but the dash form silently collapsed
484 // to the same-mem interpretation pre-fix, landing phantom stubs
485 // for any agent writing `[[team/sub-mem--target]]` in body text.
486 // Refuse and surface the colon-form as the recovery hint.
487 if let Some(dash_idx) = slug.find("--")
488 && slug[..dash_idx].contains('/')
489 {
490 let prefix = &slug[..dash_idx];
491 let suffix = &slug[dash_idx + 2..];
492 let cross_mem_form = format!("{prefix}:{suffix}");
493 let same_mem_form = if current_mem.is_empty() {
494 format!("<current-mem>:{slug}")
495 } else {
496 format!("{current_mem}:{slug}")
497 };
498 return Err(WikiLinkError::InvalidTarget {
499 raw: stripped.to_string(),
500 suggested: Some(cross_mem_form),
501 reason: format!(
502 "wiki-link target contains both '/' and '--', which is ambiguous \
503 between a cross-mem reference into a hierarchical mem and a \
504 same-mem entity at a hierarchical slug; use the colon form \
505 '[[{prefix}:{suffix}]]' for a cross-mem reference, or \
506 '[[{same_mem_form}]]' for a same-mem entity whose slug \
507 contains '--'"
508 ),
509 });
510 }
511 if let Err(reason) = validate_id_path_grammar(slug) {
512 return Err(WikiLinkError::InvalidTarget {
513 raw: stripped.to_string(),
514 suggested: wiki_link_suggestion(slug),
515 reason,
516 });
517 }
518 Ok(EntityId::new(current_mem, slug))
519}
520
521/// Permissive wiki-link decoder for read-side scanners that must
522/// tolerate pre-strict-gate on-disk drift (e.g. dangling-link
523/// reporters, body-link scanners on stored entities, archive readers
524/// for non-canonical sources). Returns an `EntityId` even for
525/// non-slug-form input — non-conformant chars flow through
526/// unchanged. Mutation paths MUST
527/// NOT use this helper; they use [`wiki_link_to_id`] and propagate
528/// the typed refusal.
529pub fn wiki_link_to_id_lenient(link: &str, current_mem: &str) -> EntityId {
530 // Strip decorations and trim to a FIXPOINT: each pass can expose
531 // work for another — an alias/anchor cut exposes trailing
532 // whitespace (`foo |label` → `foo `), and trimming that whitespace
533 // can expose a `.md` suffix the pass could not see (`x.md\r` →
534 // `x.md` → `x`; fuzz finding, corpus member `crash-b256aad3…`).
535 // The tolerant path must land on ids the generator round-trips
536 // (parse→generate is a fixpoint after one round), so it normalises
537 // until nothing changes. The loop terminates because every pass
538 // only ever shortens the string. Deliberately NOT in the shared
539 // helper: the strict decoder runs one pass and its grammar gate
540 // still refuses the leftover shapes.
541 let mut stripped = strip_wiki_link_decorations(link);
542 loop {
543 let next = strip_wiki_link_decorations(stripped.trim_end());
544 if next == stripped {
545 break;
546 }
547 stripped = next;
548 }
549 let stripped = stripped.trim_end();
550
551 if !stripped.contains("::")
552 && let Some(colon_idx) = stripped.find(':')
553 {
554 let (prefix, rest) = stripped.split_at(colon_idx);
555 let slug_part = &rest[1..];
556 if !prefix.is_empty() && !slug_part.is_empty() {
557 return EntityId::new(prefix, slug_part);
558 }
559 }
560
561 // Tier 0 — cross-mem dash form. Read-side mirror of the strict
562 // decoder's recognition so dangling-link reports and graph
563 // inspectors interpret on-disk `[[other--target]]` the same way
564 // the mutation gate writes it. Pre-strict drift on older entities
565 // keeps the bare-slug fallback below for
566 // shapes the tier-0 doesn't admit (empty prefix, hierarchical
567 // prefix, malformed slug).
568 if let Some(dash_idx) = stripped.find("--") {
569 let prefix = &stripped[..dash_idx];
570 let suffix = &stripped[dash_idx + 2..];
571 if !prefix.is_empty()
572 && !suffix.is_empty()
573 && !prefix.contains('/')
574 && validate_mem_name_grammar(prefix).is_ok()
575 && validate_id_path_grammar(suffix).is_ok()
576 {
577 return EntityId::new(prefix, suffix);
578 }
579 }
580
581 let slug = if !current_mem.is_empty() {
582 let self_prefix = format!("{current_mem}--");
583 stripped
584 .strip_prefix(self_prefix.as_str())
585 .unwrap_or(stripped)
586 } else {
587 stripped
588 };
589 EntityId::new(current_mem, slug)
590}
591
592/// Strip `[[`/`]]`, the Obsidian alias suffix `|display`, the section
593/// anchor `#section` (plus any trailing `#sub` etc — stripped from the
594/// first `#` onward), leading `../` segments, and the trailing `.md`
595/// suffix from a raw wiki-link token. Shared by the strict and
596/// lenient decoders so the pre-grammar-gate textual normalisation is
597/// byte-equivalent on both paths.
598///
599/// `#anchor` strip: Obsidian-style section anchors are display-only at
600/// the graph layer; the engine has no semantic use for them. Strip
601/// from the first `#` onward so multi-anchor forms like
602/// `target#a#b` collapse to `target` in one pass. Ordered after the
603/// `|alias` strip so `target#section|display` correctly drops both
604/// (the alias strip drops `|display` first, leaving `target#section`;
605/// the anchor strip then drops `#section`).
606pub(crate) fn strip_wiki_link_decorations(link: &str) -> String {
607 let cleaned = link.trim_start_matches("[[").trim_end_matches("]]").trim();
608 let target = match cleaned.find('|') {
609 Some(i) => &cleaned[..i],
610 None => cleaned,
611 };
612 let target_no_anchor = match target.find('#') {
613 Some(i) => &target[..i],
614 None => target,
615 };
616 let target_no_dotdot = target_no_anchor.trim_start_matches("../");
617 target_no_dotdot
618 .strip_suffix(".md")
619 .unwrap_or(target_no_dotdot)
620 .to_string()
621}
622
623/// Compute the file path for an entity given its ID and base directory.
624/// The path is relative to the mem directory.
625///
626/// `specs--architecture/result-entity` → `architecture/result-entity.md`
627pub fn id_to_file_path(id: &EntityId) -> String {
628 format!("{}.md", id.path())
629}
630
631/// Validate that an `EntityId`'s path matches the wiki-link grammar
632/// (`^[\p{Ll}\p{Lo}\p{Lm}\p{N}-]+(/[\p{Ll}\p{Lo}\p{Lm}\p{N}-]+)*$`).
633/// Same regex the strict ingress validator applies to inline
634/// `[[...]]` targets — keeping the two gates aligned ensures the
635/// relate-target path doesn't admit ids that would fail an in-body
636/// wiki-link parse.
637///
638/// Accepted character classes match what [`title_to_slug`] produces:
639/// Unicode lowercase letters (`\p{Ll}`), case-less letters
640/// (`\p{Lo}` — CJK, Arabic, Hebrew, Devanagari, Thai, …), modifier
641/// letters (`\p{Lm}` — e.g. Japanese prolonged-sound mark `ー`),
642/// any Unicode numeric (`\p{N}`), and hyphen. Mem names stay
643/// ASCII — see [`validate_mem_name_grammar`]. F1 (option B+A).
644///
645/// Returns the original path on success, an error message on failure.
646/// Callers wrap the failure into a typed envelope (e.g.
647/// `INVALID_ENTITY_ID`).
648pub fn validate_id_path_grammar(path: &str) -> Result<&str, String> {
649 use std::sync::OnceLock;
650 static RE: OnceLock<regex::Regex> = OnceLock::new();
651 let re = RE.get_or_init(|| {
652 regex::Regex::new(
653 r"^[\p{Ll}\p{Lo}\p{Lm}\p{Mn}\p{Mc}\p{N}-]+(/[\p{Ll}\p{Lo}\p{Lm}\p{Mn}\p{Mc}\p{N}-]+)*$",
654 )
655 .unwrap()
656 });
657 if re.is_match(path) {
658 Ok(path)
659 } else {
660 Err(format!(
661 "id path '{path}' does not match the wiki-link grammar — \
662 entity slugs must be lowercase Unicode letters / digits / \
663 hyphens, with path segments separated by '/'"
664 ))
665 }
666}
667
668/// Validate a mem name (left side of `--`). Hierarchical paths are
669/// first-class: mem names accept `<segment>(/<segment>)*` where each
670/// segment matches the single-segment rule (`[a-z0-9-]+`). Leading slashes,
671/// trailing slashes, double slashes, and any character outside the
672/// allowed segment alphabet are explicit refusals.
673///
674/// Flat (single-segment) names work unchanged — the
675/// regex's `(/<segment>)*` tail matches zero or more times. The
676/// storage representation uses the full path for the
677/// `__MEMSTEAD` config blob (`__MEMSTEAD:mems/<path>/config.json`), the
678/// branch ref (`refs/heads/<path>`), and the in-memory router key.
679pub fn validate_mem_name_grammar(mem: &str) -> Result<&str, String> {
680 use std::sync::OnceLock;
681 static RE: OnceLock<regex::Regex> = OnceLock::new();
682 let re = RE.get_or_init(|| regex::Regex::new(r"^[a-z0-9-]+(/[a-z0-9-]+)*$").unwrap());
683 if re.is_match(mem) {
684 Ok(mem)
685 } else {
686 Err(format!(
687 "mem name '{mem}' must match ^[a-z0-9-]+(/[a-z0-9-]+)*$ \
688 (lowercase ASCII / digits / hyphens, optionally segmented \
689 by '/' for hierarchical layouts; no leading, trailing, or \
690 double slashes)"
691 ))
692 }
693}
694
695/// Validate relationship type. Input is case-insensitive and canonicalised
696/// to uppercase; only ASCII letters and underscores are permitted.
697pub fn validate_rel_type(rel_type: &str) -> Result<String, String> {
698 let cleaned = rel_type.to_uppercase();
699 if cleaned.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !cleaned.is_empty() {
700 Ok(cleaned)
701 } else {
702 Err(format!(
703 "Invalid relationship type: \"{rel_type}\". Only ASCII letters and underscores allowed (input is canonicalised to uppercase)."
704 ))
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 /// Mem-name grammar accepts hierarchical paths and refuses
713 /// malformations. Flat (single-segment) names continue to work —
714 /// the regex's `(/<segment>)*` tail matches zero or more times.
715 #[test]
716 fn validate_mem_name_grammar_accepts_hierarchical_paths() {
717 // Flat layouts (regression).
718 assert!(validate_mem_name_grammar("specs").is_ok());
719 assert!(validate_mem_name_grammar("my-mem").is_ok());
720 assert!(validate_mem_name_grammar("v1").is_ok());
721 // Hierarchical layouts.
722 assert!(validate_mem_name_grammar("team/sub-mem").is_ok());
723 assert!(validate_mem_name_grammar("a/b/c/d").is_ok());
724 assert!(validate_mem_name_grammar("planning/2026-q1").is_ok());
725 }
726
727 /// Grammar refusals are explicit. Each malformation
728 /// case (`/team`, `team/`, `team//sub`,
729 /// uppercase / underscore / dot) returns an `Err`.
730 #[test]
731 fn validate_mem_name_grammar_refuses_malformations() {
732 // Leading slash.
733 assert!(validate_mem_name_grammar("/team/sub").is_err());
734 // Trailing slash.
735 assert!(validate_mem_name_grammar("team/sub/").is_err());
736 // Double slash.
737 assert!(validate_mem_name_grammar("team//sub").is_err());
738 // Empty.
739 assert!(validate_mem_name_grammar("").is_err());
740 // Uppercase.
741 assert!(validate_mem_name_grammar("Team/Sub").is_err());
742 // Underscore (not in allowed alphabet).
743 assert!(validate_mem_name_grammar("team_sub").is_err());
744 assert!(validate_mem_name_grammar("team/sub_mem").is_err());
745 // Dot.
746 assert!(validate_mem_name_grammar("team.sub").is_err());
747 // Space.
748 assert!(validate_mem_name_grammar("team sub").is_err());
749 }
750
751 #[test]
752 fn title_to_slug_basic() {
753 assert_eq!(title_to_slug("My Entity").unwrap(), "my-entity");
754 assert_eq!(title_to_slug("My Entity Name").unwrap(), "my-entity-name");
755 }
756
757 /// F1 (B+A) behaviour change: precomposed Latin diacritics are
758 /// preserved in the slug rather than transliterated to ASCII.
759 /// `Große Änderung` was `grosse-aenderung` pre-F1; it is now
760 /// `große-änderung`. Same applies to `naïve`, `Café résumé`,
761 /// `Łódź`, etc. — slug matches title in every script the
762 /// Unicode `is_alphanumeric` predicate accepts.
763 #[test]
764 fn title_to_slug_german() {
765 assert_eq!(title_to_slug("Große Änderung").unwrap(), "große-änderung");
766 assert_eq!(title_to_slug("Björn").unwrap(), "björn");
767 }
768
769 #[test]
770 fn title_to_slug_diacritics() {
771 assert_eq!(title_to_slug("Café résumé").unwrap(), "café-résumé");
772 assert_eq!(title_to_slug("naïve").unwrap(), "naïve");
773 }
774
775 #[test]
776 fn title_to_slug_special_chars() {
777 assert_eq!(title_to_slug("Hello, World!").unwrap(), "hello-world");
778 assert_eq!(
779 title_to_slug("--leading--trailing--").unwrap(),
780 "leading-trailing"
781 );
782 }
783
784 #[test]
785 fn title_to_slug_polish() {
786 assert_eq!(title_to_slug("Łódź").unwrap(), "łódź");
787 }
788
789 /// F1 (B+A): CJK titles round-trip cleanly. No transliteration,
790 /// no hash — the slug equals the title.
791 #[test]
792 fn title_to_slug_cjk() {
793 assert_eq!(
794 title_to_slug("日本語のタイトル").unwrap(),
795 "日本語のタイトル"
796 );
797 // Spaces still collapse to hyphens.
798 assert_eq!(title_to_slug("中文 標題").unwrap(), "中文-標題");
799 // Mixed CJK + Latin + digits.
800 assert_eq!(title_to_slug("Project 日本 v2").unwrap(), "project-日本-v2");
801 }
802
803 /// F1 (B+A): cased non-Latin scripts (Cyrillic, Greek, Armenian)
804 /// case-fold to lowercase the same way Latin does.
805 #[test]
806 fn title_to_slug_cyrillic() {
807 assert_eq!(title_to_slug("Москва").unwrap(), "москва");
808 assert_eq!(title_to_slug("Москва-проект").unwrap(), "москва-проект");
809 assert_eq!(title_to_slug("ПРОЕКТ ПЛАН").unwrap(), "проект-план");
810 }
811
812 /// F1 (B+A): Right-to-left scripts. Hebrew and Arabic letters
813 /// are `\p{Lo}` (case-less); they pass through unchanged.
814 /// Hebrew niqqud and Arabic harakat are `\p{Mn}` (nonspacing
815 /// marks) carrying the Unicode `Other_Alphabetic` property, so
816 /// Rust's `is_alphanumeric` treats them as alphabetic and the
817 /// slug filter keeps them — wiki-link round-trip is exact for
818 /// titles that include vowelization. (The wiki-link regex
819 /// accepts the wider `\p{Mn}`/`\p{Mc}` class for the same
820 /// reason; see `slug_path_regex` in `validator/strict.rs`.)
821 #[test]
822 fn title_to_slug_rtl() {
823 // Hebrew with niqqud — niqqud is preserved; spaces become hyphens.
824 assert_eq!(title_to_slug("תַּפְקִיד עברי").unwrap(), "תַּפְקִיד-עברי");
825 // Arabic with harakat — harakat preserved (same Other_Alphabetic property).
826 assert_eq!(title_to_slug("مَرْحَبًا").unwrap(), "مَرْحَبًا");
827 // Plain Hebrew without vowelization (the more common case)
828 // round-trips letter-for-letter.
829 assert_eq!(title_to_slug("שלום עולם").unwrap(), "שלום-עולם");
830 }
831
832 /// F1 (option A backstop): titles whose pipeline yields an
833 /// empty slug fall through to a deterministic short-hash id
834 /// rather than failing. Covers all-emoji, all-symbol,
835 /// all-punctuation, and empty/whitespace inputs.
836 #[test]
837 fn title_to_slug_residual_falls_back_to_hash() {
838 // All emoji.
839 let emoji = title_to_slug("🚀✨").unwrap();
840 assert!(emoji.starts_with("entity-"), "got {emoji}");
841 assert_eq!(emoji.len(), "entity-".len() + 8);
842 // Same input always produces same hash (deterministic).
843 assert_eq!(emoji, title_to_slug("🚀✨").unwrap());
844 // Different inputs produce different hashes.
845 assert_ne!(emoji, title_to_slug("🌟").unwrap());
846
847 // Empty / whitespace / punctuation-only all hit the same path.
848 assert!(title_to_slug("").unwrap().starts_with("entity-"));
849 assert!(title_to_slug(" ").unwrap().starts_with("entity-"));
850 assert!(title_to_slug("\t\n").unwrap().starts_with("entity-"));
851 assert!(title_to_slug("---").unwrap().starts_with("entity-"));
852 assert!(title_to_slug("!!!").unwrap().starts_with("entity-"));
853 assert!(title_to_slug("!?.,;").unwrap().starts_with("entity-"));
854 }
855
856 /// NFC normalization is load-bearing for cross-platform safety:
857 /// a `Café` written NFD (`Cafe` + combining-acute U+0301) and
858 /// one written NFC (single codepoint U+00E9) must produce the
859 /// same slug. Pre-F1 the pipeline NFD-decomposed and stripped
860 /// combining marks, yielding `cafe` for both — that path is
861 /// gone, so the NFC normalization step is what holds the
862 /// invariant now.
863 #[test]
864 fn title_to_slug_nfc_normalization() {
865 let nfc = "Café"; // single-codepoint é
866 let nfd = "Cafe\u{0301}"; // e + combining acute
867 assert_ne!(nfc, nfd, "NFC and NFD forms must differ at the byte level");
868 assert_eq!(
869 title_to_slug(nfc).unwrap(),
870 title_to_slug(nfd).unwrap(),
871 "NFC and NFD inputs must produce the same slug",
872 );
873 }
874
875 /// F4: the strict mutation-entry gate rejects empty titles with
876 /// `TitleEmpty` so the wire envelope can carry `reason: "empty"`
877 /// rather than silently producing a hash-fallback slug.
878 #[test]
879 fn validate_and_derive_slug_rejects_empty() {
880 // `"\t\n"` is no longer here: it contains control characters, so
881 // the more specific control-char guard fires first (see
882 // `validate_and_derive_slug_rejects_control_chars`). These cases
883 // hold no control chars and collapse to an empty slug.
884 for empty in ["", " ", "---", " - - - ", "-"] {
885 let err = validate_and_derive_slug(empty).unwrap_err();
886 let SlugError::TitleEmpty { input } = err else {
887 panic!("expected TitleEmpty for {empty:?}, got {err:?}");
888 };
889 assert_eq!(input, empty);
890 }
891 }
892
893 /// F10 + F19: any character the permissive pipeline would drop
894 /// (emoji, punctuation, math/currency symbols, path separators)
895 /// is admitted — the title is display text — with the dropped
896 /// characters reported and the slug derived exactly as the
897 /// permissive pipeline would (the old refusal's `proposed_slug`
898 /// is now simply the slug).
899 #[test]
900 fn validate_and_derive_slug_admits_and_reports_dropped_chars() {
901 let cases: &[(&str, &[char], &str)] = &[
902 ("Hello, World!", &[',', '!'], "hello-world"),
903 ("Café — résumé", &['—'], "café-résumé"),
904 ("🚀 launch", &['🚀'], "launch"),
905 ("price € 100", &['€'], "price-100"),
906 ("../escape", &['.', '/'], "escape"),
907 ("path/to/entity", &['/'], "pathtoentity"),
908 ("a\\b", &['\\'], "ab"),
909 ("Wohnung 2.OG rechts", &['.'], "wohnung-2og-rechts"),
910 (
911 "Anlage 4a – Leistungsbeschreibung",
912 &['–'],
913 "anlage-4a-leistungsbeschreibung",
914 ),
915 (
916 "Bösenberg Grundstücks GmbH & Co. KG",
917 &['&', '.'],
918 "bösenberg-grundstücks-gmbh-co-kg",
919 ),
920 ];
921 for (title, expected_dropped, expected_slug) in cases {
922 let got = validate_and_derive_slug(title)
923 .unwrap_or_else(|e| panic!("expected ok for {title:?}, got {e:?}"));
924 assert_eq!(got.slug, *expected_slug, "title={title:?}");
925 assert_eq!(got.dropped_chars, *expected_dropped, "title={title:?}");
926 // Byte-identical to the permissive pipeline, always.
927 assert_eq!(got.slug, title_to_slug(title).unwrap(), "title={title:?}");
928 }
929 }
930
931 /// F8: control characters (newline, tab,
932 /// carriage return, other C0 controls) are refused with
933 /// `TitleHasControlChars` rather than silently folded to hyphens —
934 /// they would otherwise split the stored `# H1` and truncate every
935 /// read of the title. The proposed slug is the single-line form.
936 #[test]
937 fn validate_and_derive_slug_rejects_control_chars() {
938 let cases: &[(&str, &[char], &str)] = &[
939 (
940 "Tab\tand\nnewline title",
941 &['\t', '\n'],
942 "tab-and-newline-title",
943 ),
944 ("line\rreturn", &['\r'], "line-return"),
945 ("null\u{0}byte", &['\u{0}'], "nullbyte"),
946 ];
947 for (title, expected_control, expected_proposed) in cases {
948 let err = validate_and_derive_slug(title).unwrap_err();
949 let SlugError::TitleHasControlChars {
950 input,
951 control_chars,
952 proposed_slug,
953 } = err
954 else {
955 panic!("expected TitleHasControlChars for {title:?}, got {err:?}");
956 };
957 assert_eq!(input, *title);
958 assert_eq!(control_chars, *expected_control, "title={title:?}");
959 assert_eq!(proposed_slug, *expected_proposed, "title={title:?}");
960 }
961 }
962
963 /// A plain space is whitespace but NOT a control character, so it
964 /// must keep folding to a hyphen (the control-char guard does not
965 /// narrow ordinary whitespace handling).
966 #[test]
967 fn validate_and_derive_slug_space_is_not_control() {
968 assert_eq!(validate_and_derive_slug("a b c").unwrap().slug, "a-b-c");
969 }
970
971 /// Success path — titles whose every character survives the
972 /// pipeline round-trip cleanly produce the same slug as
973 /// `title_to_slug` would.
974 #[test]
975 fn validate_and_derive_slug_success() {
976 let cases: &[(&str, &str)] = &[
977 ("My Entity", "my-entity"),
978 ("Große Änderung", "große-änderung"),
979 ("日本語のタイトル", "日本語のタイトル"),
980 ("--leading--trailing--", "leading-trailing"),
981 ("Project 日本 v2", "project-日本-v2"),
982 ];
983 for (title, expected) in cases {
984 let got = validate_and_derive_slug(title)
985 .unwrap_or_else(|e| panic!("expected ok for {title:?}, got {e:?}"));
986 assert_eq!(&got.slug, expected, "title={title:?}");
987 // A title the old grammar admitted drops nothing.
988 assert!(got.dropped_chars.is_empty(), "title={title:?}");
989 // Must agree with the permissive pipeline for accepted titles.
990 assert_eq!(got.slug, title_to_slug(title).unwrap(), "title={title:?}");
991 }
992 }
993
994 /// The strict gate runs the same NFC normalization as the
995 /// permissive pipeline, so NFC and NFD spellings of the same
996 /// title produce the same slug (or both reject).
997 #[test]
998 fn validate_and_derive_slug_nfc_normalization() {
999 let nfc = "Café";
1000 let nfd = "Cafe\u{0301}";
1001 assert_eq!(
1002 validate_and_derive_slug(nfc).unwrap().slug,
1003 validate_and_derive_slug(nfd).unwrap().slug,
1004 );
1005 }
1006
1007 /// SlugError::reason() returns the stable discriminator each
1008 /// surface uses on the `details.reason` field.
1009 #[test]
1010 fn slug_error_reason_discriminator() {
1011 let e = SlugError::TitleEmpty {
1012 input: "".to_string(),
1013 };
1014 assert_eq!(e.reason(), "empty");
1015 let e = SlugError::IdTooLong {
1016 input: "specs--x".to_string(),
1017 length: 201,
1018 max: 200,
1019 };
1020 assert_eq!(e.reason(), "id_too_long");
1021 let e = SlugError::TitleHasControlChars {
1022 input: "a\nb".to_string(),
1023 control_chars: vec!['\n'],
1024 proposed_slug: "a-b".to_string(),
1025 };
1026 assert_eq!(e.reason(), "control_chars");
1027 }
1028
1029 /// F2 + F4: a title that derives a slug whose full
1030 /// `mem--slug` id sits at the 200-char ceiling is accepted;
1031 /// one byte over is rejected with a recovery-friendly error.
1032 /// `build_id` is the canonical write-side entry, so both
1033 /// behaviours land here.
1034 #[test]
1035 fn build_id_enforces_length_cap() {
1036 let mem = "specs";
1037 // mem.len()=5, "--"=2 → 7-char prefix. Slug of 193 chars
1038 // produces a 200-char id; 194 chars trips the cap.
1039 let just_fits = "a".repeat(ENTITY_ID_MAX_LEN - mem.len() - 2);
1040 let ok = build_id(mem, &just_fits).expect("at-cap id must pass");
1041 assert_eq!(ok.as_ref().chars().count(), ENTITY_ID_MAX_LEN);
1042
1043 let one_over = "a".repeat(ENTITY_ID_MAX_LEN - mem.len() - 2 + 1);
1044 let err = build_id(mem, &one_over).unwrap_err();
1045 let SlugError::IdTooLong { input, length, max } = err else {
1046 panic!("expected IdTooLong, got {err:?}");
1047 };
1048 // `input` echoes the composed id, not the title, so it agrees
1049 // with `length`.
1050 assert_eq!(input, format!("{mem}--{one_over}"));
1051 assert_eq!(input.chars().count(), length);
1052 assert_eq!(length, ENTITY_ID_MAX_LEN + 1);
1053 assert_eq!(max, ENTITY_ID_MAX_LEN);
1054 }
1055
1056 #[test]
1057 fn build_id_basic() {
1058 assert_eq!(
1059 build_id("specs", "My Entity").unwrap().0,
1060 "specs--my-entity"
1061 );
1062 }
1063
1064 /// F1 (B+A): non-Latin titles round-trip through `build_id`.
1065 #[test]
1066 fn build_id_non_latin() {
1067 assert_eq!(
1068 build_id("specs", "日本語のタイトル").unwrap().0,
1069 "specs--日本語のタイトル",
1070 );
1071 assert_eq!(
1072 build_id("specs", "Москва-проект").unwrap().0,
1073 "specs--москва-проект",
1074 );
1075 }
1076
1077 #[test]
1078 fn file_path_to_id_basic() {
1079 assert_eq!(
1080 file_path_to_id("architecture/result-entity.md", "specs").0,
1081 "specs--architecture/result-entity"
1082 );
1083 assert_eq!(
1084 file_path_to_id("result-entity.md", "specs").0,
1085 "specs--result-entity"
1086 );
1087 }
1088
1089 #[test]
1090 fn wiki_link_to_id_basic() {
1091 assert_eq!(
1092 wiki_link_to_id("result-entity", "specs").unwrap().0,
1093 "specs--result-entity"
1094 );
1095 assert_eq!(
1096 wiki_link_to_id("parent/child/entity", "specs").unwrap().0,
1097 "specs--parent/child/entity"
1098 );
1099 }
1100
1101 #[test]
1102 fn wiki_link_to_id_strips_alias() {
1103 assert_eq!(
1104 wiki_link_to_id("target|Display Name", "specs").unwrap().0,
1105 "specs--target"
1106 );
1107 }
1108
1109 #[test]
1110 fn wiki_link_to_id_strips_prefix_and_suffix() {
1111 assert_eq!(
1112 wiki_link_to_id("../parent/entity.md", "specs").unwrap().0,
1113 "specs--parent/entity"
1114 );
1115 }
1116
1117 /// Agents writing the canonical fully-qualified id `[[mem--slug]]`
1118 /// must not be doubly-prefixed into `mem--mem--slug`.
1119 #[test]
1120 fn wiki_link_to_id_strips_redundant_self_prefix() {
1121 assert_eq!(
1122 wiki_link_to_id("specs--result-entity", "specs").unwrap().0,
1123 "specs--result-entity"
1124 );
1125 assert_eq!(
1126 wiki_link_to_id("test-mem-mini--engine", "test-mem-mini")
1127 .unwrap()
1128 .0,
1129 "test-mem-mini--engine"
1130 );
1131 assert_eq!(
1132 wiki_link_to_id("specs--target.md|Display", "specs")
1133 .unwrap()
1134 .0,
1135 "specs--target"
1136 );
1137 assert_eq!(
1138 wiki_link_to_id("specs--parent/child", "specs").unwrap().0,
1139 "specs--parent/child"
1140 );
1141 // Self-prefix stripping is one-shot, not iterative — a second
1142 // embedded `<current_mem>--` is preserved so cross-mem-style
1143 // drift stays visible.
1144 assert_eq!(
1145 wiki_link_to_id("specs--specs--slug", "specs").unwrap().0,
1146 "specs--specs--slug"
1147 );
1148 }
1149
1150 /// Cross-mem dash form `[[<mem>--<slug>]]` routes to the named
1151 /// mem rather than silently re-prepending the source mem into a
1152 /// phantom `specs--other--entity` stub.
1153 /// The cross-mem policy gate (alias-synthesis pass) refuses the
1154 /// auto-stub when the workspace policy denies the direction —
1155 /// that gate is exercised in engine-layer tests.
1156 #[test]
1157 fn wiki_link_to_id_tier_zero_cross_mem_dash_form() {
1158 assert_eq!(
1159 wiki_link_to_id("other--entity", "specs").unwrap().0,
1160 "other--entity"
1161 );
1162 assert_eq!(
1163 wiki_link_to_id("nonexistent-mem--target", "specs")
1164 .unwrap()
1165 .0,
1166 "nonexistent-mem--target"
1167 );
1168 }
1169
1170 /// Tier-0 dash form collapses cleanly when the named mem is the
1171 /// source mem — equivalent to the self-prefix-strip fast path
1172 /// for bare-slug authoring.
1173 #[test]
1174 fn wiki_link_to_id_tier_zero_self_mem_dash_form() {
1175 assert_eq!(
1176 wiki_link_to_id("specs--target", "specs").unwrap().0,
1177 "specs--target"
1178 );
1179 }
1180
1181 /// Tier-0 only admits single-segment mem names — the
1182 /// hierarchical dash form stays on the colon Tier-2 recovery
1183 /// path. The pre-existing slash-dash ambiguity refusal is what
1184 /// fires here (cross-mem into a hierarchical mem is
1185 /// grammatically ambiguous with a same-mem hierarchical slug).
1186 #[test]
1187 fn wiki_link_to_id_tier_zero_refuses_hierarchical_prefix() {
1188 let err = wiki_link_to_id("team/sub-mem--target", "specs").unwrap_err();
1189 match err {
1190 WikiLinkError::InvalidTarget { suggested, .. } => {
1191 assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
1192 }
1193 other => panic!("expected InvalidTarget, got {other:?}"),
1194 }
1195 }
1196
1197 /// Section anchors strip as a display decoration alongside `|alias`,
1198 /// `../`, and `.md`. Single anchor, multi-anchor, and the
1199 /// combined anchor+alias form all collapse to the underlying
1200 /// slug-form id.
1201 #[test]
1202 fn wiki_link_to_id_strips_section_anchor() {
1203 assert_eq!(
1204 wiki_link_to_id("login-service#identity", "specs")
1205 .unwrap()
1206 .0,
1207 "specs--login-service"
1208 );
1209 assert_eq!(
1210 wiki_link_to_id("specs--login-service#identity", "specs")
1211 .unwrap()
1212 .0,
1213 "specs--login-service"
1214 );
1215 // Multi-anchor — strip from first `#`.
1216 assert_eq!(
1217 wiki_link_to_id("specs--target#a#b", "specs").unwrap().0,
1218 "specs--target"
1219 );
1220 // Combined anchor + alias.
1221 assert_eq!(
1222 wiki_link_to_id("specs--target#section|Display", "specs")
1223 .unwrap()
1224 .0,
1225 "specs--target"
1226 );
1227 }
1228
1229 /// Cross-mem routing and anchor stripping compose: a cross-mem
1230 /// anchored form resolves under tier-0 and strips the anchor.
1231 #[test]
1232 fn wiki_link_to_id_cross_mem_anchored_composes() {
1233 assert_eq!(
1234 wiki_link_to_id("other--target#section", "specs").unwrap().0,
1235 "other--target"
1236 );
1237 }
1238
1239 /// Empty `current_mem` opts out of self-prefix stripping so a
1240 /// literal leading `--` (which would never legitimately occur, but
1241 /// could collide with `format!("{mem}--", mem="")`) stays intact.
1242 #[test]
1243 fn wiki_link_to_id_empty_mem_does_not_strip() {
1244 assert_eq!(wiki_link_to_id("--weird", "").unwrap().0, "----weird");
1245 }
1246
1247 #[test]
1248 fn wiki_link_to_id_tier_two_cross_mem() {
1249 assert_eq!(
1250 wiki_link_to_id("engine:health", "plugin").unwrap().0,
1251 "engine--health"
1252 );
1253 assert_eq!(
1254 wiki_link_to_id("engine:architecture/result", "plugin")
1255 .unwrap()
1256 .0,
1257 "engine--architecture/result"
1258 );
1259 }
1260
1261 #[test]
1262 fn wiki_link_to_id_tier_two_self_prefix_collapses() {
1263 assert_eq!(
1264 wiki_link_to_id("specs:foo", "specs").unwrap().0,
1265 "specs--foo"
1266 );
1267 assert_eq!(
1268 wiki_link_to_id("specs:foo", "specs").unwrap(),
1269 wiki_link_to_id("foo", "specs").unwrap()
1270 );
1271 }
1272
1273 #[test]
1274 fn wiki_link_to_id_tier_two_combines_with_alias_and_md() {
1275 assert_eq!(
1276 wiki_link_to_id("engine:health.md|See health", "plugin")
1277 .unwrap()
1278 .0,
1279 "engine--health"
1280 );
1281 }
1282
1283 #[test]
1284 fn wiki_link_to_id_tier_two_accepts_hierarchical_prefix() {
1285 assert_eq!(
1286 wiki_link_to_id("external/engine:health", "plugin")
1287 .unwrap()
1288 .0,
1289 "external/engine--health"
1290 );
1291 }
1292
1293 #[test]
1294 fn wiki_link_to_id_tier_one_strips_hierarchical_self_prefix() {
1295 assert_eq!(
1296 wiki_link_to_id("team/sub-mem--auth-service", "team/sub-mem")
1297 .unwrap()
1298 .0,
1299 "team/sub-mem--auth-service"
1300 );
1301 }
1302
1303 #[test]
1304 fn wiki_link_to_id_tier_one_bare_slug_from_hierarchical_mem() {
1305 assert_eq!(
1306 wiki_link_to_id("auth-service", "team/sub-mem").unwrap().0,
1307 "team/sub-mem--auth-service"
1308 );
1309 }
1310
1311 /// `::` is reserved syntax — strict refusal. The slug-grammar gate
1312 /// refuses the `:` character outright.
1313 #[test]
1314 fn wiki_link_to_id_double_colon_refuses() {
1315 let err = wiki_link_to_id("engine::health", "plugin").unwrap_err();
1316 assert!(
1317 matches!(err, WikiLinkError::InvalidTarget { .. }),
1318 "got {err:?}"
1319 );
1320 }
1321
1322 /// Empty halves around the colon refuse under strict mode — the
1323 /// `:` character isn't in the slug-grammar character class, so
1324 /// the Tier-1 fallback fails.
1325 #[test]
1326 fn wiki_link_to_id_empty_tier_two_halves_refuse() {
1327 assert!(matches!(
1328 wiki_link_to_id(":foo", "specs").unwrap_err(),
1329 WikiLinkError::InvalidTarget { .. }
1330 ));
1331 assert!(matches!(
1332 wiki_link_to_id("engine:", "specs").unwrap_err(),
1333 WikiLinkError::InvalidTarget { .. }
1334 ));
1335 }
1336
1337 /// Natural-form (uppercase + whitespace) refuses with
1338 /// `InvalidTarget` and a `title_to_slug`-derived suggestion the
1339 /// agent lifts directly into a retry.
1340 #[test]
1341 fn wiki_link_to_id_natural_form_refuses_with_suggestion() {
1342 let err = wiki_link_to_id("Knowledge Graph", "specs").unwrap_err();
1343 let WikiLinkError::InvalidTarget { raw, suggested, .. } = err else {
1344 panic!("expected InvalidTarget, got {err:?}");
1345 };
1346 assert_eq!(raw, "Knowledge Graph");
1347 assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
1348 }
1349
1350 /// Tier-2 with natural-form slug suggests `mem:slug`
1351 /// preserving the prefix. The agent rewrites only the slug part.
1352 #[test]
1353 fn wiki_link_to_id_tier_two_natural_slug_refuses_with_prefixed_suggestion() {
1354 let err = wiki_link_to_id("engine:Health Check", "plugin").unwrap_err();
1355 let WikiLinkError::InvalidTarget { raw, suggested, .. } = err else {
1356 panic!("expected InvalidTarget, got {err:?}");
1357 };
1358 assert_eq!(raw, "engine:Health Check");
1359 assert_eq!(suggested.as_deref(), Some("engine:health-check"));
1360 }
1361
1362 /// Tier-1 dash form
1363 /// with `/` in the would-be mem prefix is grammatically
1364 /// ambiguous (cross-mem into a hierarchical mem vs same-mem
1365 /// hierarchical slug). Refusal carries the colon-form as
1366 /// `suggested` so the agent's recovery is a one-character edit.
1367 #[test]
1368 fn wiki_link_to_id_hierarchical_dash_form_refuses_with_colon_suggestion() {
1369 let err = wiki_link_to_id("team/sub-mem--auth-service", "test").unwrap_err();
1370 let WikiLinkError::InvalidTarget {
1371 raw,
1372 suggested,
1373 reason,
1374 } = err
1375 else {
1376 panic!("expected InvalidTarget, got {err:?}");
1377 };
1378 assert_eq!(raw, "team/sub-mem--auth-service");
1379 assert_eq!(suggested.as_deref(), Some("team/sub-mem:auth-service"));
1380 // Reason names both disambiguations.
1381 assert!(
1382 reason.contains("team/sub-mem:auth-service"),
1383 "reason must surface the cross-mem colon form: {reason}"
1384 );
1385 assert!(
1386 reason.contains("test:team/sub-mem--auth-service"),
1387 "reason must surface the same-mem hierarchical form: {reason}"
1388 );
1389 }
1390
1391 /// For self-prefixed dash form, tier-0 splits on the FIRST `--`, so
1392 /// `[[test--team/sub--target]]` resolves as mem `test`, slug
1393 /// `team/sub--target` — a same-mem entity with a hierarchical
1394 /// slug containing `--`. The ambiguity gate in the slug position
1395 /// does not apply because the mem/slug boundary is
1396 /// pinned by tier-0's grammar.
1397 #[test]
1398 fn wiki_link_to_id_self_prefixed_dash_form_resolves_via_tier_zero() {
1399 let id = wiki_link_to_id("test--team/sub--target", "test").unwrap();
1400 assert_eq!(id.mem(), "test");
1401 assert_eq!(id.path(), "team/sub--target");
1402 }
1403
1404 /// A bare hierarchical slug (no `--`) continues to
1405 /// resolve to a same-mem entity. The refusal is keyed on the
1406 /// simultaneous presence of `/` AND `--`, not on `/` alone.
1407 #[test]
1408 fn wiki_link_to_id_bare_hierarchical_slug_still_resolves() {
1409 let id = wiki_link_to_id("team/sub-mem", "test").unwrap();
1410 assert_eq!(id.mem(), "test");
1411 assert_eq!(id.path(), "team/sub-mem");
1412 }
1413
1414 /// Colon-form for cross-mem hierarchical reference
1415 /// continues to resolve correctly (the canonical disambiguation).
1416 #[test]
1417 fn wiki_link_to_id_hierarchical_colon_form_resolves_cross_mem() {
1418 let id = wiki_link_to_id("team/sub-mem:auth-service", "test").unwrap();
1419 assert_eq!(id.mem(), "team/sub-mem");
1420 assert_eq!(id.path(), "auth-service");
1421 }
1422
1423 /// Flat `[[<other-mem>--<slug>]]` routes to the named mem under
1424 /// tier-0 rather than silently re-prefixing with the source mem into
1425 /// a phantom `test--other--target` stub. The cross-mem policy
1426 /// gate enforces routing legality in the alias-synthesis pass —
1427 /// that gate is exercised in engine-layer tests.
1428 #[test]
1429 fn wiki_link_to_id_flat_foreign_dash_form_routes_via_tier_zero() {
1430 let id = wiki_link_to_id("other--target", "test").unwrap();
1431 assert_eq!(id.mem(), "other");
1432 assert_eq!(id.path(), "target");
1433 }
1434
1435 /// Tier-2 with non-ASCII mem prefix refuses with
1436 /// `InvalidMemName`. Mem names are ASCII-only operator
1437 /// identifiers; the agent cannot auto-slugify them.
1438 #[test]
1439 fn wiki_link_to_id_tier_two_bad_mem_refuses_with_distinct_error() {
1440 let err = wiki_link_to_id("Other Mem:foo", "plugin").unwrap_err();
1441 let WikiLinkError::InvalidMemName { raw, .. } = err else {
1442 panic!("expected InvalidMemName, got {err:?}");
1443 };
1444 assert_eq!(raw, "Other Mem");
1445 }
1446
1447 /// Pathological inputs (empty, all punctuation) refuse
1448 /// with `suggested: None`.
1449 #[test]
1450 fn wiki_link_to_id_pathological_input_no_suggestion() {
1451 let err = wiki_link_to_id("!!!", "specs").unwrap_err();
1452 let WikiLinkError::InvalidTarget { suggested, .. } = err else {
1453 panic!("expected InvalidTarget, got {err:?}");
1454 };
1455 assert!(suggested.is_none(), "got {suggested:?}");
1456 }
1457
1458 /// Slug-form across every script family the slug
1459 /// pipeline accepts round-trips through the strict gate.
1460 #[test]
1461 fn wiki_link_to_id_accepts_slug_form_across_scripts() {
1462 let cases: &[(&str, &str)] = &[
1463 ("knowledge-graph", "v--knowledge-graph"),
1464 ("الرسم-البياني-للمعرفة", "v--الرسم-البياني-للمعرفة"),
1465 ("ज्ञान-ग्राफ", "v--ज्ञान-ग्राफ"),
1466 ("知识图谱", "v--知识图谱"),
1467 ("知識グラフ", "v--知識グラフ"),
1468 ("กราฟความรู้", "v--กราฟความรู้"),
1469 ("ידע-גרף", "v--ידע-גרף"),
1470 ];
1471 for (input, expected) in cases {
1472 let id = wiki_link_to_id(input, "v")
1473 .unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"));
1474 assert_eq!(&id.0, expected, "input={input:?}");
1475 }
1476 }
1477
1478 /// The lenient decoder preserves pre-strict behaviour
1479 /// for read-side scanners that must tolerate on-disk drift.
1480 /// Round-trip equivalence on the inputs the strict gate accepts.
1481 #[test]
1482 fn wiki_link_to_id_lenient_matches_strict_on_valid_input() {
1483 let inputs = &["knowledge-graph", "engine:health", "parent/child"];
1484 for input in inputs {
1485 let strict = wiki_link_to_id(input, "specs").unwrap();
1486 let lenient = wiki_link_to_id_lenient(input, "specs");
1487 assert_eq!(strict, lenient, "input={input:?}");
1488 }
1489 }
1490
1491 /// The lenient decoder accepts what the strict gate
1492 /// refuses, surfacing the literal drift for read-side reporting.
1493 #[test]
1494 fn wiki_link_to_id_lenient_admits_drift() {
1495 assert_eq!(
1496 wiki_link_to_id_lenient("Knowledge Graph", "specs").0,
1497 "specs--Knowledge Graph"
1498 );
1499 assert_eq!(
1500 wiki_link_to_id_lenient("engine::health", "plugin").0,
1501 "plugin--engine::health"
1502 );
1503 }
1504
1505 /// Fuzz finding (long tier, frontmatter target, 2026-08-24; corpus
1506 /// member `crash-ac181b5d…`): the alias/anchor cuts inside the
1507 /// decoration strip run after its whitespace trim, so they exposed
1508 /// trailing whitespace that reached the lenient id, and the
1509 /// generated row re-parsed to a different id on the next round.
1510 /// The lenient decoder now trims what the cuts expose; the strict
1511 /// gate still refuses those shapes (no widening).
1512 #[test]
1513 fn wiki_link_to_id_lenient_trims_whitespace_exposed_by_alias_and_anchor_cuts() {
1514 assert_eq!(
1515 wiki_link_to_id_lenient("foo |label", "specs").0,
1516 "specs--foo"
1517 );
1518 assert_eq!(
1519 wiki_link_to_id_lenient("foo\r\n#anchor", "specs").0,
1520 "specs--foo"
1521 );
1522 // The idempotence shape itself: a second decode of the first
1523 // decode's output is byte-identical.
1524 let once = wiki_link_to_id_lenient("parent: x\r\n#tail", "specs");
1525 let twice = wiki_link_to_id_lenient(&format!("{}:{}", once.mem(), once.path()), "specs");
1526 assert_eq!(once, twice);
1527 // Strict is untouched: whitespace exposed by an anchor cut
1528 // still fails the grammar gate.
1529 assert!(wiki_link_to_id("foo #anchor", "specs").is_err());
1530 }
1531
1532 #[test]
1533 fn entity_id_parts() {
1534 let id = EntityId::new("specs", "parent/child");
1535 assert_eq!(id.mem(), "specs");
1536 assert_eq!(id.path(), "parent/child");
1537 assert_eq!(id.name(), "child");
1538 }
1539
1540 #[test]
1541 fn entity_id_no_mem() {
1542 let id = EntityId("result-entity".to_string());
1543 assert_eq!(id.mem(), "");
1544 assert_eq!(id.path(), "result-entity");
1545 assert_eq!(id.name(), "result-entity");
1546 }
1547
1548 #[test]
1549 fn id_to_file_path_basic() {
1550 let id = EntityId::new("specs", "architecture/result-entity");
1551 assert_eq!(id_to_file_path(&id), "architecture/result-entity.md");
1552 }
1553
1554 #[test]
1555 fn validate_rel_type_valid() {
1556 assert_eq!(validate_rel_type("PART_OF").unwrap(), "PART_OF");
1557 assert_eq!(validate_rel_type("uses").unwrap(), "USES");
1558 }
1559
1560 #[test]
1561 fn validate_rel_type_invalid() {
1562 assert!(validate_rel_type("has spaces").is_err());
1563 assert!(validate_rel_type("").is_err());
1564 }
1565
1566 /// TITLE_GRAMMAR_RULE conformance: the documented sentence and the
1567 /// validator agree — any single-line text is admitted, characters
1568 /// outside the slug alphabet are dropped from the slug and
1569 /// reported, and control characters are the rejection. If this
1570 /// test fails, either the validator's behaviour or the constant
1571 /// changed alone; change them together.
1572 #[test]
1573 fn title_grammar_rule_matches_validator_behaviour() {
1574 // Admitted with nothing dropped: Unicode alphanumerics,
1575 // whitespace, hyphen.
1576 for ok in [
1577 "Plain Title",
1578 "hyphen-ated",
1579 "Große Änderung", // non-ASCII alphanumerics
1580 "日本語 タイトル",
1581 "nbsp\u{a0}space", // non-control whitespace folds to hyphen
1582 ] {
1583 let got = validate_and_derive_slug(ok)
1584 .unwrap_or_else(|e| panic!("rule says {ok:?} is accepted, got {e:?}"));
1585 assert!(got.dropped_chars.is_empty(), "{ok:?} drops nothing");
1586 }
1587 // Admitted per the rule, with characters outside the slug
1588 // alphabet dropped from the slug and reported — the plenum
1589 // collision list plus representative symbol/punctuation cases.
1590 for (title, dropped) in [
1591 ("v1.0", '.'),
1592 ("a (draft)", '('),
1593 ("a (draft", '('),
1594 ("either/or", '/'),
1595 ("re: title", ':'),
1596 ("a \u{2014} b", '\u{2014}'), // em dash
1597 ("hello!", '!'),
1598 ] {
1599 let got = validate_and_derive_slug(title)
1600 .unwrap_or_else(|e| panic!("rule says {title:?} is admitted, got {e:?}"));
1601 assert!(
1602 got.dropped_chars.contains(&dropped),
1603 "{title:?}: the divergence report names {dropped:?}, got {:?}",
1604 got.dropped_chars
1605 );
1606 assert!(
1607 !got.slug.is_empty(),
1608 "{title:?}: a slug still derives from the surviving characters"
1609 );
1610 }
1611 // Control-class whitespace (tab, newline) is the rejection,
1612 // per the rule's parenthetical.
1613 for title in ["tabs\tinside", "line\nbreak"] {
1614 assert!(
1615 matches!(
1616 validate_and_derive_slug(title),
1617 Err(SlugError::TitleHasControlChars { .. })
1618 ),
1619 "rule says {title:?} is rejected as a control character"
1620 );
1621 }
1622 }
1623}