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.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
#![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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user