pub fn slugify(title: &str) -> String {
let mut out = String::with_capacity(title.len());
let mut prev_separator = false;
for ch in title.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_separator = false;
} else if !out.is_empty() && !prev_separator {
out.push('-');
prev_separator = true;
}
}
if out.ends_with('-') {
out.pop();
}
if out.len() > 80 {
out.truncate(80);
let trimmed = out.trim_end_matches('-');
out = trimmed.to_string();
}
if out.is_empty() {
"untitled".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_title_becomes_kebab() {
assert_eq!(slugify("Fix: Login crash!"), "fix-login-crash");
}
#[test]
fn lowercases_kept_ascii() {
assert_eq!(
slugify("Add DOCUMENTATION for the Feature"),
"add-documentation-for-the-feature"
);
}
#[test]
fn punctuation_and_spaces_collapse_to_single_hyphen() {
assert_eq!(slugify("a b c"), "a-b-c");
assert_eq!(slugify("one & two / three"), "one-two-three");
}
#[test]
fn leading_and_trailing_separators_are_trimmed() {
assert_eq!(slugify("!!!-- hello ---"), "hello");
}
#[test]
fn unicode_is_handled_without_panic_and_never_leaks_non_ascii() {
let slug = slugify("héllo wörld ☃ café");
assert!(
slug.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
);
assert_ne!(slug, "");
}
#[test]
fn empty_and_meta_titles_fall_back_to_untitled() {
assert_eq!(slugify(""), "untitled");
assert_eq!(slugify(" "), "untitled");
assert_eq!(slugify("!!! --- ???"), "untitled");
}
#[test]
fn long_titles_are_capped_at_80_chars_without_trailing_hyphen() {
let long = format!("{} tail", "x-".repeat(200));
let slug = slugify(&long);
assert!(slug.len() <= 80);
assert!(!slug.ends_with('-'));
assert!(slug.starts_with("x-x-"));
}
#[test]
fn hyphen_run_in_middle_collapses() {
assert_eq!(slugify("fix login---crash"), "fix-login-crash");
}
}