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