use super::EntityId;
use unicode_normalization::UnicodeNormalization;
pub const ENTITY_ID_MAX_LEN: usize = 200;
#[derive(Debug, thiserror::Error)]
pub enum SlugError {
#[error(
"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"
)]
IdTooLong {
input: String,
length: usize,
max: usize,
},
#[error("title is empty or contains no slug-meaningful characters")]
TitleEmpty { input: String },
#[error(
"title \"{input}\" contains character(s) that the slug pipeline drops: {invalid_chars:?} — \
retry with a sanitised title (proposed slug: \"{proposed_slug}\")"
)]
TitleHasInvalidChars {
input: String,
invalid_chars: Vec<char>,
proposed_slug: String,
},
#[error(
"title {input:?} contains control character(s) {control_chars:?} that would split the stored heading — \
retry with a single-line title (proposed slug: \"{proposed_slug}\")"
)]
TitleHasControlChars {
input: String,
control_chars: Vec<char>,
proposed_slug: String,
},
}
impl SlugError {
pub fn reason(&self) -> &'static str {
match self {
SlugError::IdTooLong { .. } => "id_too_long",
SlugError::TitleEmpty { .. } => "empty",
SlugError::TitleHasInvalidChars { .. } => "invalid_chars",
SlugError::TitleHasControlChars { .. } => "control_chars",
}
}
}
pub fn build_id(mem: &str, title: &str) -> Result<EntityId, SlugError> {
let slug = title_to_slug(title)?;
let id = EntityId::new(mem, &slug);
enforce_id_length(id.as_ref())?;
Ok(id)
}
pub fn enforce_id_length(id: &str) -> Result<(), SlugError> {
if id.chars().count() > ENTITY_ID_MAX_LEN {
return Err(SlugError::IdTooLong {
input: id.to_string(),
length: id.chars().count(),
max: ENTITY_ID_MAX_LEN,
});
}
Ok(())
}
pub fn title_to_slug(title: &str) -> Result<String, SlugError> {
let normalized: String = title.nfc().collect();
let slug: String = normalized
.chars()
.flat_map(|c| c.to_lowercase())
.map(|c| if c.is_whitespace() { '-' } else { c })
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
return Ok(format!("entity-{}", short_hash(title)));
}
Ok(slug)
}
pub fn validate_and_derive_slug(title: &str) -> Result<String, SlugError> {
let normalized: String = title.nfc().collect();
let case_folded: String = normalized.chars().flat_map(|c| c.to_lowercase()).collect();
let mut control_chars: Vec<char> = Vec::new();
for c in case_folded.chars() {
if c.is_control() && !control_chars.contains(&c) {
control_chars.push(c);
}
}
if !control_chars.is_empty() {
let proposed = title_to_slug(title).unwrap_or_default();
return Err(SlugError::TitleHasControlChars {
input: title.to_string(),
control_chars,
proposed_slug: proposed,
});
}
let mut invalid_chars: Vec<char> = Vec::new();
for c in case_folded.chars() {
if c.is_whitespace() || c == '-' || c.is_alphanumeric() {
continue;
}
if !invalid_chars.contains(&c) {
invalid_chars.push(c);
}
}
if !invalid_chars.is_empty() {
let proposed = title_to_slug(title).unwrap_or_default();
return Err(SlugError::TitleHasInvalidChars {
input: title.to_string(),
invalid_chars,
proposed_slug: proposed,
});
}
let slug: String = case_folded
.chars()
.map(|c| if c.is_whitespace() { '-' } else { c })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
return Err(SlugError::TitleEmpty {
input: title.to_string(),
});
}
Ok(slug)
}
fn short_hash(input: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(input.as_bytes());
format!(
"{:02x}{:02x}{:02x}{:02x}",
digest[0], digest[1], digest[2], digest[3]
)
}
pub fn file_path_to_id(path: &str, mem: &str) -> EntityId {
let stripped = path.strip_suffix(".md").unwrap_or(path);
EntityId::new(mem, stripped)
}
#[derive(Debug, thiserror::Error, Clone)]
pub enum WikiLinkError {
#[error("mem prefix '{raw}' is not a valid mem name: {reason}")]
InvalidMemName { raw: String, reason: String },
#[error("wiki-link target '{raw}' is not slug-form: {reason}")]
InvalidTarget {
raw: String,
suggested: Option<String>,
reason: String,
},
}
fn wiki_link_suggestion(raw: &str) -> Option<String> {
let derived = title_to_slug(raw).ok()?;
if derived.is_empty() || derived.starts_with("entity-") {
return None;
}
validate_id_path_grammar(&derived)
.is_ok()
.then_some(derived)
}
pub fn wiki_link_to_id(link: &str, current_mem: &str) -> Result<EntityId, WikiLinkError> {
let stripped = strip_wiki_link_decorations(link);
if !stripped.contains("::")
&& let Some(colon_idx) = stripped.find(':')
{
let (prefix, rest) = stripped.split_at(colon_idx);
let slug_part = &rest[1..];
if !prefix.is_empty() && !slug_part.is_empty() {
if let Err(reason) = validate_mem_name_grammar(prefix) {
return Err(WikiLinkError::InvalidMemName {
raw: prefix.to_string(),
reason,
});
}
if let Err(reason) = validate_id_path_grammar(slug_part) {
let suggested = wiki_link_suggestion(slug_part).map(|s| format!("{prefix}:{s}"));
return Err(WikiLinkError::InvalidTarget {
raw: stripped.to_string(),
suggested,
reason,
});
}
return Ok(EntityId::new(prefix, slug_part));
}
}
if let Some(dash_idx) = stripped.find("--") {
let prefix = &stripped[..dash_idx];
let suffix = &stripped[dash_idx + 2..];
if !prefix.is_empty()
&& !suffix.is_empty()
&& !prefix.contains('/')
&& validate_mem_name_grammar(prefix).is_ok()
&& validate_id_path_grammar(suffix).is_ok()
{
return Ok(EntityId::new(prefix, suffix));
}
}
let slug = if !current_mem.is_empty() {
let self_prefix = format!("{current_mem}--");
stripped
.strip_prefix(self_prefix.as_str())
.unwrap_or(&stripped)
} else {
&stripped
};
if let Some(dash_idx) = slug.find("--")
&& slug[..dash_idx].contains('/')
{
let prefix = &slug[..dash_idx];
let suffix = &slug[dash_idx + 2..];
let cross_mem_form = format!("{prefix}:{suffix}");
let same_mem_form = if current_mem.is_empty() {
format!("<current-mem>:{slug}")
} else {
format!("{current_mem}:{slug}")
};
return Err(WikiLinkError::InvalidTarget {
raw: stripped.to_string(),
suggested: Some(cross_mem_form),
reason: format!(
"wiki-link target contains both '/' and '--', which is ambiguous \
between a cross-mem reference into a hierarchical mem and a \
same-mem entity at a hierarchical slug; use the colon form \
'[[{prefix}:{suffix}]]' for a cross-mem reference, or \
'[[{same_mem_form}]]' for a same-mem entity whose slug \
contains '--'"
),
});
}
if let Err(reason) = validate_id_path_grammar(slug) {
return Err(WikiLinkError::InvalidTarget {
raw: stripped.to_string(),
suggested: wiki_link_suggestion(slug),
reason,
});
}
Ok(EntityId::new(current_mem, slug))
}
pub fn wiki_link_to_id_lenient(link: &str, current_mem: &str) -> EntityId {
let stripped = strip_wiki_link_decorations(link);
if !stripped.contains("::")
&& let Some(colon_idx) = stripped.find(':')
{
let (prefix, rest) = stripped.split_at(colon_idx);
let slug_part = &rest[1..];
if !prefix.is_empty() && !slug_part.is_empty() {
return EntityId::new(prefix, slug_part);
}
}
if let Some(dash_idx) = stripped.find("--") {
let prefix = &stripped[..dash_idx];
let suffix = &stripped[dash_idx + 2..];
if !prefix.is_empty()
&& !suffix.is_empty()
&& !prefix.contains('/')
&& validate_mem_name_grammar(prefix).is_ok()
&& validate_id_path_grammar(suffix).is_ok()
{
return EntityId::new(prefix, suffix);
}
}
let slug = if !current_mem.is_empty() {
let self_prefix = format!("{current_mem}--");
stripped
.strip_prefix(self_prefix.as_str())
.unwrap_or(&stripped)
} else {
&stripped
};
EntityId::new(current_mem, slug)
}
fn strip_wiki_link_decorations(link: &str) -> String {
let cleaned = link.trim_start_matches("[[").trim_end_matches("]]").trim();
let target = match cleaned.find('|') {
Some(i) => &cleaned[..i],
None => cleaned,
};
let target_no_anchor = match target.find('#') {
Some(i) => &target[..i],
None => target,
};
let target_no_dotdot = target_no_anchor.trim_start_matches("../");
target_no_dotdot
.strip_suffix(".md")
.unwrap_or(target_no_dotdot)
.to_string()
}
pub fn id_to_file_path(id: &EntityId) -> String {
format!("{}.md", id.path())
}
pub fn validate_id_path_grammar(path: &str) -> Result<&str, String> {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(
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}-]+)*$",
)
.unwrap()
});
if re.is_match(path) {
Ok(path)
} else {
Err(format!(
"id path '{path}' does not match the wiki-link grammar — \
entity slugs must be lowercase Unicode letters / digits / \
hyphens, with path segments separated by '/'"
))
}
}
pub fn validate_mem_name_grammar(mem: &str) -> Result<&str, String> {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| regex::Regex::new(r"^[a-z0-9-]+(/[a-z0-9-]+)*$").unwrap());
if re.is_match(mem) {
Ok(mem)
} else {
Err(format!(
"mem name '{mem}' must match ^[a-z0-9-]+(/[a-z0-9-]+)*$ \
(lowercase ASCII / digits / hyphens, optionally segmented \
by '/' for hierarchical layouts; no leading, trailing, or \
double slashes)"
))
}
}
pub fn validate_rel_type(rel_type: &str) -> Result<String, String> {
let cleaned = rel_type.to_uppercase();
if cleaned.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !cleaned.is_empty() {
Ok(cleaned)
} else {
Err(format!(
"Invalid relationship type: \"{rel_type}\". Only ASCII letters and underscores allowed (input is canonicalised to uppercase)."
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_mem_name_grammar_accepts_hierarchical_paths() {
assert!(validate_mem_name_grammar("specs").is_ok());
assert!(validate_mem_name_grammar("my-mem").is_ok());
assert!(validate_mem_name_grammar("v1").is_ok());
assert!(validate_mem_name_grammar("team/sub-mem").is_ok());
assert!(validate_mem_name_grammar("a/b/c/d").is_ok());
assert!(validate_mem_name_grammar("planning/2026-q1").is_ok());
}
#[test]
fn validate_mem_name_grammar_refuses_malformations() {
assert!(validate_mem_name_grammar("/team/sub").is_err());
assert!(validate_mem_name_grammar("team/sub/").is_err());
assert!(validate_mem_name_grammar("team//sub").is_err());
assert!(validate_mem_name_grammar("").is_err());
assert!(validate_mem_name_grammar("Team/Sub").is_err());
assert!(validate_mem_name_grammar("team_sub").is_err());
assert!(validate_mem_name_grammar("team/sub_mem").is_err());
assert!(validate_mem_name_grammar("team.sub").is_err());
assert!(validate_mem_name_grammar("team sub").is_err());
}
#[test]
fn title_to_slug_basic() {
assert_eq!(title_to_slug("My Entity").unwrap(), "my-entity");
assert_eq!(title_to_slug("My Entity Name").unwrap(), "my-entity-name");
}
#[test]
fn title_to_slug_german() {
assert_eq!(title_to_slug("Große Änderung").unwrap(), "große-änderung");
assert_eq!(title_to_slug("Björn").unwrap(), "björn");
}
#[test]
fn title_to_slug_diacritics() {
assert_eq!(title_to_slug("Café résumé").unwrap(), "café-résumé");
assert_eq!(title_to_slug("naïve").unwrap(), "naïve");
}
#[test]
fn title_to_slug_special_chars() {
assert_eq!(title_to_slug("Hello, World!").unwrap(), "hello-world");
assert_eq!(
title_to_slug("--leading--trailing--").unwrap(),
"leading-trailing"
);
}
#[test]
fn title_to_slug_polish() {
assert_eq!(title_to_slug("Łódź").unwrap(), "łódź");
}
#[test]
fn title_to_slug_cjk() {
assert_eq!(
title_to_slug("日本語のタイトル").unwrap(),
"日本語のタイトル"
);
assert_eq!(title_to_slug("中文 標題").unwrap(), "中文-標題");
assert_eq!(title_to_slug("Project 日本 v2").unwrap(), "project-日本-v2");
}
#[test]
fn title_to_slug_cyrillic() {
assert_eq!(title_to_slug("Москва").unwrap(), "москва");
assert_eq!(title_to_slug("Москва-проект").unwrap(), "москва-проект");
assert_eq!(title_to_slug("ПРОЕКТ ПЛАН").unwrap(), "проект-план");
}
#[test]
fn title_to_slug_rtl() {
assert_eq!(title_to_slug("תַּפְקִיד עברי").unwrap(), "תַּפְקִיד-עברי");
assert_eq!(title_to_slug("مَرْحَبًا").unwrap(), "مَرْحَبًا");
assert_eq!(title_to_slug("שלום עולם").unwrap(), "שלום-עולם");
}
#[test]
fn title_to_slug_residual_falls_back_to_hash() {
let emoji = title_to_slug("🚀✨").unwrap();
assert!(emoji.starts_with("entity-"), "got {emoji}");
assert_eq!(emoji.len(), "entity-".len() + 8);
assert_eq!(emoji, title_to_slug("🚀✨").unwrap());
assert_ne!(emoji, title_to_slug("🌟").unwrap());
assert!(title_to_slug("").unwrap().starts_with("entity-"));
assert!(title_to_slug(" ").unwrap().starts_with("entity-"));
assert!(title_to_slug("\t\n").unwrap().starts_with("entity-"));
assert!(title_to_slug("---").unwrap().starts_with("entity-"));
assert!(title_to_slug("!!!").unwrap().starts_with("entity-"));
assert!(title_to_slug("!?.,;").unwrap().starts_with("entity-"));
}
#[test]
fn title_to_slug_nfc_normalization() {
let nfc = "Café"; let nfd = "Cafe\u{0301}"; assert_ne!(nfc, nfd, "NFC and NFD forms must differ at the byte level");
assert_eq!(
title_to_slug(nfc).unwrap(),
title_to_slug(nfd).unwrap(),
"NFC and NFD inputs must produce the same slug",
);
}
#[test]
fn validate_and_derive_slug_rejects_empty() {
for empty in ["", " ", "---", " - - - ", "-"] {
let err = validate_and_derive_slug(empty).unwrap_err();
let SlugError::TitleEmpty { input } = err else {
panic!("expected TitleEmpty for {empty:?}, got {err:?}");
};
assert_eq!(input, empty);
}
}
#[test]
fn validate_and_derive_slug_rejects_invalid_chars() {
let cases: &[(&str, &[char], &str)] = &[
("Hello, World!", &[',', '!'], "hello-world"),
("Café — résumé", &['—'], "café-résumé"),
("🚀 launch", &['🚀'], "launch"),
("price € 100", &['€'], "price-100"),
("../escape", &['.', '/'], "escape"),
("path/to/entity", &['/'], "pathtoentity"),
("a\\b", &['\\'], "ab"),
];
for (title, expected_invalid, expected_proposed) in cases {
let err = validate_and_derive_slug(title).unwrap_err();
let SlugError::TitleHasInvalidChars {
input,
invalid_chars,
proposed_slug,
} = err
else {
panic!("expected TitleHasInvalidChars for {title:?}, got {err:?}");
};
assert_eq!(input, *title);
assert_eq!(invalid_chars, *expected_invalid, "title={title:?}");
assert_eq!(proposed_slug, *expected_proposed, "title={title:?}");
}
}
#[test]
fn validate_and_derive_slug_rejects_control_chars() {
let cases: &[(&str, &[char], &str)] = &[
(
"Tab\tand\nnewline title",
&['\t', '\n'],
"tab-and-newline-title",
),
("line\rreturn", &['\r'], "line-return"),
("null\u{0}byte", &['\u{0}'], "nullbyte"),
];
for (title, expected_control, expected_proposed) in cases {
let err = validate_and_derive_slug(title).unwrap_err();
let SlugError::TitleHasControlChars {
input,
control_chars,
proposed_slug,
} = err
else {
panic!("expected TitleHasControlChars for {title:?}, got {err:?}");
};
assert_eq!(input, *title);
assert_eq!(control_chars, *expected_control, "title={title:?}");
assert_eq!(proposed_slug, *expected_proposed, "title={title:?}");
}
}
#[test]
fn validate_and_derive_slug_space_is_not_control() {
assert_eq!(validate_and_derive_slug("a b c").unwrap(), "a-b-c");
}
#[test]
fn validate_and_derive_slug_success() {
let cases: &[(&str, &str)] = &[
("My Entity", "my-entity"),
("Große Änderung", "große-änderung"),
("日本語のタイトル", "日本語のタイトル"),
("--leading--trailing--", "leading-trailing"),
("Project 日本 v2", "project-日本-v2"),
];
for (title, expected) in cases {
let got = validate_and_derive_slug(title)
.unwrap_or_else(|e| panic!("expected ok for {title:?}, got {e:?}"));
assert_eq!(&got, expected, "title={title:?}");
assert_eq!(got, title_to_slug(title).unwrap(), "title={title:?}");
}
}
#[test]
fn validate_and_derive_slug_nfc_normalization() {
let nfc = "Café";
let nfd = "Cafe\u{0301}";
assert_eq!(
validate_and_derive_slug(nfc).unwrap(),
validate_and_derive_slug(nfd).unwrap(),
);
}
#[test]
fn slug_error_reason_discriminator() {
let e = SlugError::TitleEmpty {
input: "".to_string(),
};
assert_eq!(e.reason(), "empty");
let e = SlugError::TitleHasInvalidChars {
input: "x!".to_string(),
invalid_chars: vec!['!'],
proposed_slug: "x".to_string(),
};
assert_eq!(e.reason(), "invalid_chars");
let e = SlugError::IdTooLong {
input: "specs--x".to_string(),
length: 201,
max: 200,
};
assert_eq!(e.reason(), "id_too_long");
let e = SlugError::TitleHasControlChars {
input: "a\nb".to_string(),
control_chars: vec!['\n'],
proposed_slug: "a-b".to_string(),
};
assert_eq!(e.reason(), "control_chars");
}
#[test]
fn build_id_enforces_length_cap() {
let mem = "specs";
let just_fits = "a".repeat(ENTITY_ID_MAX_LEN - mem.len() - 2);
let ok = build_id(mem, &just_fits).expect("at-cap id must pass");
assert_eq!(ok.as_ref().chars().count(), ENTITY_ID_MAX_LEN);
let one_over = "a".repeat(ENTITY_ID_MAX_LEN - mem.len() - 2 + 1);
let err = build_id(mem, &one_over).unwrap_err();
let SlugError::IdTooLong { input, length, max } = err else {
panic!("expected IdTooLong, got {err:?}");
};
assert_eq!(input, format!("{mem}--{one_over}"));
assert_eq!(input.chars().count(), length);
assert_eq!(length, ENTITY_ID_MAX_LEN + 1);
assert_eq!(max, ENTITY_ID_MAX_LEN);
}
#[test]
fn build_id_basic() {
assert_eq!(
build_id("specs", "My Entity").unwrap().0,
"specs--my-entity"
);
}
#[test]
fn build_id_non_latin() {
assert_eq!(
build_id("specs", "日本語のタイトル").unwrap().0,
"specs--日本語のタイトル",
);
assert_eq!(
build_id("specs", "Москва-проект").unwrap().0,
"specs--москва-проект",
);
}
#[test]
fn file_path_to_id_basic() {
assert_eq!(
file_path_to_id("architecture/result-entity.md", "specs").0,
"specs--architecture/result-entity"
);
assert_eq!(
file_path_to_id("result-entity.md", "specs").0,
"specs--result-entity"
);
}
#[test]
fn wiki_link_to_id_basic() {
assert_eq!(
wiki_link_to_id("result-entity", "specs").unwrap().0,
"specs--result-entity"
);
assert_eq!(
wiki_link_to_id("parent/child/entity", "specs").unwrap().0,
"specs--parent/child/entity"
);
}
#[test]
fn wiki_link_to_id_strips_alias() {
assert_eq!(
wiki_link_to_id("target|Display Name", "specs").unwrap().0,
"specs--target"
);
}
#[test]
fn wiki_link_to_id_strips_prefix_and_suffix() {
assert_eq!(
wiki_link_to_id("../parent/entity.md", "specs").unwrap().0,
"specs--parent/entity"
);
}
#[test]
fn wiki_link_to_id_strips_redundant_self_prefix() {
assert_eq!(
wiki_link_to_id("specs--result-entity", "specs").unwrap().0,
"specs--result-entity"
);
assert_eq!(
wiki_link_to_id("test-mem-mini--engine", "test-mem-mini")
.unwrap()
.0,
"test-mem-mini--engine"
);
assert_eq!(
wiki_link_to_id("specs--target.md|Display", "specs")
.unwrap()
.0,
"specs--target"
);
assert_eq!(
wiki_link_to_id("specs--parent/child", "specs").unwrap().0,
"specs--parent/child"
);
assert_eq!(
wiki_link_to_id("specs--specs--slug", "specs").unwrap().0,
"specs--specs--slug"
);
}
#[test]
fn wiki_link_to_id_tier_zero_cross_mem_dash_form() {
assert_eq!(
wiki_link_to_id("other--entity", "specs").unwrap().0,
"other--entity"
);
assert_eq!(
wiki_link_to_id("nonexistent-mem--target", "specs")
.unwrap()
.0,
"nonexistent-mem--target"
);
}
#[test]
fn wiki_link_to_id_tier_zero_self_mem_dash_form() {
assert_eq!(
wiki_link_to_id("specs--target", "specs").unwrap().0,
"specs--target"
);
}
#[test]
fn wiki_link_to_id_tier_zero_refuses_hierarchical_prefix() {
let err = wiki_link_to_id("team/sub-mem--target", "specs").unwrap_err();
match err {
WikiLinkError::InvalidTarget { suggested, .. } => {
assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
}
other => panic!("expected InvalidTarget, got {other:?}"),
}
}
#[test]
fn wiki_link_to_id_strips_section_anchor() {
assert_eq!(
wiki_link_to_id("login-service#identity", "specs")
.unwrap()
.0,
"specs--login-service"
);
assert_eq!(
wiki_link_to_id("specs--login-service#identity", "specs")
.unwrap()
.0,
"specs--login-service"
);
assert_eq!(
wiki_link_to_id("specs--target#a#b", "specs").unwrap().0,
"specs--target"
);
assert_eq!(
wiki_link_to_id("specs--target#section|Display", "specs")
.unwrap()
.0,
"specs--target"
);
}
#[test]
fn wiki_link_to_id_cross_mem_anchored_composes() {
assert_eq!(
wiki_link_to_id("other--target#section", "specs").unwrap().0,
"other--target"
);
}
#[test]
fn wiki_link_to_id_empty_mem_does_not_strip() {
assert_eq!(wiki_link_to_id("--weird", "").unwrap().0, "----weird");
}
#[test]
fn wiki_link_to_id_tier_two_cross_mem() {
assert_eq!(
wiki_link_to_id("engine:health", "plugin").unwrap().0,
"engine--health"
);
assert_eq!(
wiki_link_to_id("engine:architecture/result", "plugin")
.unwrap()
.0,
"engine--architecture/result"
);
}
#[test]
fn wiki_link_to_id_tier_two_self_prefix_collapses() {
assert_eq!(
wiki_link_to_id("specs:foo", "specs").unwrap().0,
"specs--foo"
);
assert_eq!(
wiki_link_to_id("specs:foo", "specs").unwrap(),
wiki_link_to_id("foo", "specs").unwrap()
);
}
#[test]
fn wiki_link_to_id_tier_two_combines_with_alias_and_md() {
assert_eq!(
wiki_link_to_id("engine:health.md|See health", "plugin")
.unwrap()
.0,
"engine--health"
);
}
#[test]
fn wiki_link_to_id_tier_two_accepts_hierarchical_prefix() {
assert_eq!(
wiki_link_to_id("external/engine:health", "plugin")
.unwrap()
.0,
"external/engine--health"
);
}
#[test]
fn wiki_link_to_id_tier_one_strips_hierarchical_self_prefix() {
assert_eq!(
wiki_link_to_id("team/sub-mem--auth-service", "team/sub-mem")
.unwrap()
.0,
"team/sub-mem--auth-service"
);
}
#[test]
fn wiki_link_to_id_tier_one_bare_slug_from_hierarchical_mem() {
assert_eq!(
wiki_link_to_id("auth-service", "team/sub-mem").unwrap().0,
"team/sub-mem--auth-service"
);
}
#[test]
fn wiki_link_to_id_double_colon_refuses() {
let err = wiki_link_to_id("engine::health", "plugin").unwrap_err();
assert!(
matches!(err, WikiLinkError::InvalidTarget { .. }),
"got {err:?}"
);
}
#[test]
fn wiki_link_to_id_empty_tier_two_halves_refuse() {
assert!(matches!(
wiki_link_to_id(":foo", "specs").unwrap_err(),
WikiLinkError::InvalidTarget { .. }
));
assert!(matches!(
wiki_link_to_id("engine:", "specs").unwrap_err(),
WikiLinkError::InvalidTarget { .. }
));
}
#[test]
fn wiki_link_to_id_natural_form_refuses_with_suggestion() {
let err = wiki_link_to_id("Knowledge Graph", "specs").unwrap_err();
let WikiLinkError::InvalidTarget { raw, suggested, .. } = err else {
panic!("expected InvalidTarget, got {err:?}");
};
assert_eq!(raw, "Knowledge Graph");
assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
}
#[test]
fn wiki_link_to_id_tier_two_natural_slug_refuses_with_prefixed_suggestion() {
let err = wiki_link_to_id("engine:Health Check", "plugin").unwrap_err();
let WikiLinkError::InvalidTarget { raw, suggested, .. } = err else {
panic!("expected InvalidTarget, got {err:?}");
};
assert_eq!(raw, "engine:Health Check");
assert_eq!(suggested.as_deref(), Some("engine:health-check"));
}
#[test]
fn wiki_link_to_id_hierarchical_dash_form_refuses_with_colon_suggestion() {
let err = wiki_link_to_id("team/sub-mem--auth-service", "test").unwrap_err();
let WikiLinkError::InvalidTarget {
raw,
suggested,
reason,
} = err
else {
panic!("expected InvalidTarget, got {err:?}");
};
assert_eq!(raw, "team/sub-mem--auth-service");
assert_eq!(suggested.as_deref(), Some("team/sub-mem:auth-service"));
assert!(
reason.contains("team/sub-mem:auth-service"),
"reason must surface the cross-mem colon form: {reason}"
);
assert!(
reason.contains("test:team/sub-mem--auth-service"),
"reason must surface the same-mem hierarchical form: {reason}"
);
}
#[test]
fn wiki_link_to_id_self_prefixed_dash_form_resolves_via_tier_zero() {
let id = wiki_link_to_id("test--team/sub--target", "test").unwrap();
assert_eq!(id.mem(), "test");
assert_eq!(id.path(), "team/sub--target");
}
#[test]
fn wiki_link_to_id_bare_hierarchical_slug_still_resolves() {
let id = wiki_link_to_id("team/sub-mem", "test").unwrap();
assert_eq!(id.mem(), "test");
assert_eq!(id.path(), "team/sub-mem");
}
#[test]
fn wiki_link_to_id_hierarchical_colon_form_resolves_cross_mem() {
let id = wiki_link_to_id("team/sub-mem:auth-service", "test").unwrap();
assert_eq!(id.mem(), "team/sub-mem");
assert_eq!(id.path(), "auth-service");
}
#[test]
fn wiki_link_to_id_flat_foreign_dash_form_routes_via_tier_zero() {
let id = wiki_link_to_id("other--target", "test").unwrap();
assert_eq!(id.mem(), "other");
assert_eq!(id.path(), "target");
}
#[test]
fn wiki_link_to_id_tier_two_bad_mem_refuses_with_distinct_error() {
let err = wiki_link_to_id("Other Mem:foo", "plugin").unwrap_err();
let WikiLinkError::InvalidMemName { raw, .. } = err else {
panic!("expected InvalidMemName, got {err:?}");
};
assert_eq!(raw, "Other Mem");
}
#[test]
fn wiki_link_to_id_pathological_input_no_suggestion() {
let err = wiki_link_to_id("!!!", "specs").unwrap_err();
let WikiLinkError::InvalidTarget { suggested, .. } = err else {
panic!("expected InvalidTarget, got {err:?}");
};
assert!(suggested.is_none(), "got {suggested:?}");
}
#[test]
fn wiki_link_to_id_accepts_slug_form_across_scripts() {
let cases: &[(&str, &str)] = &[
("knowledge-graph", "v--knowledge-graph"),
("الرسم-البياني-للمعرفة", "v--الرسم-البياني-للمعرفة"),
("ज्ञान-ग्राफ", "v--ज्ञान-ग्राफ"),
("知识图谱", "v--知识图谱"),
("知識グラフ", "v--知識グラフ"),
("กราฟความรู้", "v--กราฟความรู้"),
("ידע-גרף", "v--ידע-גרף"),
];
for (input, expected) in cases {
let id = wiki_link_to_id(input, "v")
.unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"));
assert_eq!(&id.0, expected, "input={input:?}");
}
}
#[test]
fn wiki_link_to_id_lenient_matches_strict_on_valid_input() {
let inputs = &["knowledge-graph", "engine:health", "parent/child"];
for input in inputs {
let strict = wiki_link_to_id(input, "specs").unwrap();
let lenient = wiki_link_to_id_lenient(input, "specs");
assert_eq!(strict, lenient, "input={input:?}");
}
}
#[test]
fn wiki_link_to_id_lenient_admits_drift() {
assert_eq!(
wiki_link_to_id_lenient("Knowledge Graph", "specs").0,
"specs--Knowledge Graph"
);
assert_eq!(
wiki_link_to_id_lenient("engine::health", "plugin").0,
"plugin--engine::health"
);
}
#[test]
fn entity_id_parts() {
let id = EntityId::new("specs", "parent/child");
assert_eq!(id.mem(), "specs");
assert_eq!(id.path(), "parent/child");
assert_eq!(id.name(), "child");
}
#[test]
fn entity_id_no_mem() {
let id = EntityId("result-entity".to_string());
assert_eq!(id.mem(), "");
assert_eq!(id.path(), "result-entity");
assert_eq!(id.name(), "result-entity");
}
#[test]
fn id_to_file_path_basic() {
let id = EntityId::new("specs", "architecture/result-entity");
assert_eq!(id_to_file_path(&id), "architecture/result-entity.md");
}
#[test]
fn validate_rel_type_valid() {
assert_eq!(validate_rel_type("PART_OF").unwrap(), "PART_OF");
assert_eq!(validate_rel_type("uses").unwrap(), "USES");
}
#[test]
fn validate_rel_type_invalid() {
assert!(validate_rel_type("has spaces").is_err());
assert!(validate_rel_type("").is_err());
}
}