#![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 { 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")); } }