use crate::constants::MAX_ENTITY_TYPE_LEN;
use crate::errors::AppError;
use crate::i18n::validation;
pub const CANONICAL_ENTITY_TYPES: &[&str] = &[
"concept",
"dashboard",
"date",
"decision",
"file",
"incident",
"issue_tracker",
"location",
"memory",
"organization",
"person",
"project",
"tool",
];
pub const DEFAULT_ENTITY_TYPE: &str = "concept";
#[must_use]
pub fn is_canonical_entity_type(s: &str) -> bool {
CANONICAL_ENTITY_TYPES.contains(&s)
}
pub fn normalize_entity_type(s: &str) -> Result<String, AppError> {
let normalized = s.trim().to_lowercase().replace('-', "_");
if normalized.is_empty() {
return Err(AppError::Validation(validation::entity_type_blank()));
}
if normalized.contains('\n') || normalized.contains('\r') {
return Err(AppError::Validation(validation::entity_type_has_newline(
&normalized,
)));
}
if normalized.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::Validation(validation::entity_type_digits_only(
&normalized,
)));
}
if normalized.chars().count() > MAX_ENTITY_TYPE_LEN {
return Err(AppError::Validation(validation::entity_type_too_long(
&normalized,
MAX_ENTITY_TYPE_LEN,
)));
}
Ok(normalized)
}
#[must_use]
pub fn normalize_entity_type_or_default(s: &str) -> String {
normalize_entity_type(s).unwrap_or_else(|_| DEFAULT_ENTITY_TYPE.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_set_has_thirteen_sorted_members() {
assert_eq!(CANONICAL_ENTITY_TYPES.len(), 13);
let mut sorted = CANONICAL_ENTITY_TYPES.to_vec();
sorted.sort_unstable();
assert_eq!(
sorted.as_slice(),
CANONICAL_ENTITY_TYPES,
"kept sorted so diagnostics are stable"
);
}
#[test]
fn canonical_labels_are_recognised() {
for kind in CANONICAL_ENTITY_TYPES {
assert!(is_canonical_entity_type(kind), "{kind} must be canonical");
}
}
#[test]
fn shape_normalisation_is_case_and_hyphen_insensitive() {
assert_eq!(
normalize_entity_type(" Issue-Tracker ").unwrap(),
"issue_tracker"
);
assert_eq!(normalize_entity_type("PERSON").unwrap(), "person");
}
#[test]
fn non_canonical_labels_survive_verbatim() {
for label in ["crate", "gap", "flag", "migration", "schema", "framework"] {
let normalized = normalize_entity_type(label).unwrap();
assert_eq!(normalized, label, "{label} must not be folded");
assert!(
!is_canonical_entity_type(&normalized),
"{label} is not canonical, but is still storable"
);
}
}
#[test]
fn previously_folded_labels_are_no_longer_folded() {
for label in [
"framework",
"library",
"method",
"metric",
"platform",
"protocol",
] {
assert_eq!(normalize_entity_type(label).unwrap(), label);
}
}
#[test]
fn blank_and_digit_only_labels_are_refused() {
assert!(normalize_entity_type("").is_err());
assert!(normalize_entity_type(" ").is_err());
assert!(normalize_entity_type("42").is_err());
}
#[test]
fn line_breaks_are_refused() {
assert!(normalize_entity_type("person\nrole").is_err());
assert!(normalize_entity_type("person\rrole").is_err());
}
#[test]
fn overlong_labels_are_refused_by_characters_not_bytes() {
let long = "a".repeat(MAX_ENTITY_TYPE_LEN + 1);
assert!(normalize_entity_type(&long).is_err());
let at_limit = "a".repeat(MAX_ENTITY_TYPE_LEN);
assert!(normalize_entity_type(&at_limit).is_ok());
let accented = "á".repeat(MAX_ENTITY_TYPE_LEN);
assert!(normalize_entity_type(&accented).is_ok());
}
#[test]
fn default_is_used_only_when_normalisation_fails() {
assert_eq!(normalize_entity_type_or_default("crate"), "crate");
assert_eq!(normalize_entity_type_or_default(""), DEFAULT_ENTITY_TYPE);
}
}