Files
zesdex/crates/zesdex-utils/src/slug.rs
T
asepharyana be0a9582bb refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
2026-07-17 09:08:41 +07:00

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"));
}
}