137 lines
3.4 KiB
Rust
137 lines
3.4 KiB
Rust
#![allow(
|
|||
|
|
clippy::cast_possible_truncation,
|
||
|
|
clippy::cast_sign_loss,
|
||
|
|
clippy::cast_precision_loss,
|
||
|
|
clippy::cast_possible_wrap
|
||
|
|
)]
|
||
|
|
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
|
||
|
|
const MAX_SLUG_LENGTH: usize = 80;
|
||
|
|
|
||
|
|
/// Convert an arbitrary string into a URL / filesystem-safe slug.
|
||
|
|
///
|
||
|
|
/// The algorithm:
|
||
|
|
/// 1. Lowercase the input.
|
||
|
|
/// 2. Replace any sequence of non-alphanumeric characters (except `-` and `_`)
|
||
|
|
/// with a single `-`.
|
||
|
|
/// 3. Strip leading/trailing `-`.
|
||
|
|
/// 4. If the result is empty, return `None`.
|
||
|
|
/// 5. Truncate to 80 characters, breaking at the last full word if possible.
|
||
|
|
///
|
||
|
|
/// Returns `None` if the slug would be completely empty.
|
||
|
|
pub fn slugify(s: &str) -> Option<String> {
|
||
|
|
if s.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
|
||
|
|
let lower = s.to_lowercase();
|
||
|
|
|
||
|
|
// Replace non-alphanumeric (except dash/underscore) sequences with '-'
|
||
|
|
let mut slug = String::with_capacity(lower.len());
|
||
|
|
let mut prev_was_sep = false;
|
||
|
|
|
||
|
|
for c in lower.chars() {
|
||
|
|
if c.is_alphanumeric() {
|
||
|
|
slug.push(c);
|
||
|
|
prev_was_sep = false;
|
||
|
|
} else if !prev_was_sep {
|
||
|
|
slug.push('-');
|
||
|
|
prev_was_sep = true;
|
||
|
|
}
|
||
|
|
// else skip consecutive separators
|
||
|
|
}
|
||
|
|
|
||
|
|
// Strip leading/trailing dashes
|
||
|
|
let slug = slug.trim_matches('-').to_string();
|
||
|
|
|
||
|
|
if slug.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Truncate to MAX_SLUG_LENGTH
|
||
|
|
let slug = if slug.len() > MAX_SLUG_LENGTH {
|
||
|
|
let mut truncated: String = slug.chars().take(MAX_SLUG_LENGTH).collect();
|
||
|
|
|
||
|
|
// Trim trailing dash from broken word boundary
|
||
|
|
while truncated.ends_with('-') {
|
||
|
|
truncated.pop();
|
||
|
|
}
|
||
|
|
|
||
|
|
if truncated.is_empty() {
|
||
|
|
// If trimming removed everything, take the raw max-length prefix
|
||
|
|
slug.chars().take(MAX_SLUG_LENGTH).collect()
|
||
|
|
} else {
|
||
|
|
truncated
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
slug
|
||
|
|
};
|
||
|
|
|
||
|
|
Some(slug)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Join `base` with a slugified version of `name`.
|
||
|
|
///
|
||
|
|
/// If `slugify(name)` returns `None`, the name is used as-is (lowercased).
|
||
|
|
pub fn slug_path(base: &Path, name: &str) -> PathBuf {
|
||
|
|
match slugify(name) {
|
||
|
|
Some(slug) => base.join(slug),
|
||
|
|
None => base.join(name.to_lowercase()),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_basic() {
|
||
|
|
assert_eq!(slugify("Hello World"), Some("hello-world".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_special_chars() {
|
||
|
|
assert_eq!(slugify("Hello, World! #2"), Some("hello-world-2".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_empty() {
|
||
|
|
assert_eq!(slugify(""), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_only_separators() {
|
||
|
|
assert_eq!(slugify("!!! @@"), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_collapse() {
|
||
|
|
assert_eq!(slugify("a b---c___d"), Some("a-b-c-d".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_leading_trailing() {
|
||
|
|
assert_eq!(slugify("---hello---"), Some("hello".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_dash_underscore_as_separator() {
|
||
|
|
assert_eq!(slugify("my-slug_here"), Some("my-slug-here".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slugify_truncate() {
|
||
|
|
let long = "a".repeat(100);
|
||
|
|
let slug = slugify(&long);
|
||
|
|
assert!(slug.is_some());
|
||
|
|
assert!(slug.as_ref().unwrap().len() <= MAX_SLUG_LENGTH);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_slug_path() {
|
||
|
|
let base = Path::new("/tmp");
|
||
|
|
assert_eq!(slug_path(base, "Hello World"), Path::new("/tmp/hello-world"));
|
||
|
|
}
|
||
|
|
}
|