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