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