Skip to main content

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}