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,65 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence.
|
||||
///
|
||||
/// OSC-52 (`\x1b]52;c;<base64>\x1b\\`) is supported by many terminal emulators
|
||||
/// (iTerm2, Kitty, tmux, etc.) and allows writing to the system clipboard
|
||||
/// without external binaries.
|
||||
///
|
||||
/// The `output` parameter should be a writable handle to the terminal (e.g.
|
||||
/// `io::stdout()` or `io::stderr()`).
|
||||
pub fn write_osc52(output: &mut impl Write, text: &str) -> io::Result<()> {
|
||||
use base64::Engine as _;
|
||||
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
|
||||
// OSC-52: ESC ] 52 ; c ; <base64> ST
|
||||
// Where c = "c" for clipboard, ST = ESC \
|
||||
write!(output, "\x1b]52;c;{encoded}\x1b\\")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_write_osc52_output_format() {
|
||||
let mut buf = Vec::new();
|
||||
write_osc52(&mut buf, "hello").unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
|
||||
// Should start with OSC sequence
|
||||
assert!(output.starts_with("\x1b]52;c;"), "should start with OSC52 prefix");
|
||||
|
||||
// Should have base64 payload
|
||||
assert!(output.contains("aGVsbG8="), "should contain base64 of 'hello'");
|
||||
|
||||
// Should end with ST
|
||||
assert!(output.ends_with("\x1b\\"), "should end with string terminator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_osc52_empty() {
|
||||
let mut buf = Vec::new();
|
||||
write_osc52(&mut buf, "").unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert_eq!(output, "\x1b]52;c;\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_osc52_unicode() {
|
||||
let mut buf = Vec::new();
|
||||
write_osc52(&mut buf, "日本語").unwrap();
|
||||
let output = String::from_utf8(buf).unwrap();
|
||||
assert!(output.starts_with("\x1b]52;c;"));
|
||||
assert!(output.ends_with("\x1b\\"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Unified error type for the zesdex codebase.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Wraps an I/O error.
|
||||
Io(std::io::Error),
|
||||
/// Wraps a JSON serialization/deserialization error.
|
||||
Serde(serde_json::Error),
|
||||
/// A generic parse failure with a message.
|
||||
Parse(String),
|
||||
/// A resource was not found.
|
||||
NotFound(String),
|
||||
/// Invalid input was provided.
|
||||
InvalidInput(String),
|
||||
/// The session is locked and cannot be accessed.
|
||||
SessionLocked,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Self::Serde(e) => write!(f, "serialization error: {e}"),
|
||||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||||
Self::NotFound(resource) => write!(f, "not found: {resource}"),
|
||||
Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
|
||||
Self::SessionLocked => write!(f, "session is locked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(e) => Some(e),
|
||||
Self::Serde(e) => Some(e),
|
||||
Self::Parse(_) | Self::NotFound(_) | Self::InvalidInput(_) | Self::SessionLocked => {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From conversions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::Serde(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: anyhow already provides `From<E> for anyhow::Error` for all
|
||||
// `E: std::error::Error + Send + Sync + 'static`, which our `Error` satisfies.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type alias
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convenience alias for `Result<T, zesdex_utils::Error>`.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional impls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Error {
|
||||
/// Create a `Parse` error.
|
||||
pub fn parse(msg: impl Into<String>) -> Self {
|
||||
Self::Parse(msg.into())
|
||||
}
|
||||
|
||||
/// Create a `NotFound` error.
|
||||
pub fn not_found(resource: impl Into<String>) -> Self {
|
||||
Self::NotFound(resource.into())
|
||||
}
|
||||
|
||||
/// Create an `InvalidInput` error.
|
||||
pub fn invalid_input(msg: impl Into<String>) -> Self {
|
||||
Self::InvalidInput(msg.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod clipboard;
|
||||
pub mod error;
|
||||
pub mod logger;
|
||||
pub mod pagination;
|
||||
pub mod sanitize;
|
||||
pub mod slug;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use pagination::{paginate, Paginated};
|
||||
pub use sanitize::{
|
||||
is_valid_session_id, sanitize_filename, sanitize_html, sanitize_path, truncate,
|
||||
};
|
||||
pub use slug::{slug_path, slugify};
|
||||
@@ -0,0 +1,100 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::fmt::writer::MakeWriter;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// A [`MakeWriter`] that writes to a log file, falling back to `/dev/null`.
|
||||
#[derive(Clone)]
|
||||
struct LogFileWriter {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl std::io::Write for LogFileWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => file.write(buf),
|
||||
Err(_) => {
|
||||
// fallback: write to /dev/null
|
||||
let mut null = fs::OpenOptions::new().write(true).open("/dev/null")?;
|
||||
null.write(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(file) => file.sync_all(),
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for LogFileWriter {
|
||||
type Writer = LogFileWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise tracing/logging for the application.
|
||||
///
|
||||
/// Creates a log directory at `$DATA_DIR/zesdex/logs/` and opens a log file
|
||||
/// with a timestamped name in append mode. If the directory cannot be created
|
||||
/// or the file cannot be opened, falls back to `/dev/null` so that tracing
|
||||
/// never panics at startup.
|
||||
///
|
||||
/// The subscriber uses `RUST_LOG` / `ZESDEX_LOG` env-filtering.
|
||||
pub fn init_logging() -> Result<(), anyhow::Error> {
|
||||
// ── determine log directory ──────────────────────────────────────
|
||||
let data_dir = dirs::data_dir()
|
||||
.map(|p| p.join("zesdex"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/zesdex"));
|
||||
|
||||
let log_dir = data_dir.join("logs");
|
||||
|
||||
// ── create dir (best-effort) ─────────────────────────────────────
|
||||
if let Err(e) = fs::create_dir_all(&log_dir) {
|
||||
// If we can't create the directory, log via eprintln and continue
|
||||
// with a /dev/null fallback.
|
||||
eprintln!("[zesdex-utils::logger] failed to create log dir {log_dir:?}: {e}");
|
||||
}
|
||||
|
||||
// ── build log file path ──────────────────────────────────────────
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
|
||||
let log_path = log_dir.join(format!("zesdex-{timestamp}.log"));
|
||||
|
||||
// ── initialise tracing subscriber ────────────────────────────────
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_from_env("ZESDEX_LOG"))
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
let writer = LogFileWriter { path: log_path };
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(writer)
|
||||
.with_ansi(false) // log files don't need ANSI colours
|
||||
.with_target(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A generic paginated response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Paginated<T> {
|
||||
/// Items on the current page.
|
||||
pub items: Vec<T>,
|
||||
/// Total number of items across all pages.
|
||||
pub total: usize,
|
||||
/// Current page number (1-based).
|
||||
pub page: usize,
|
||||
/// Number of items per page.
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
impl<T> Paginated<T> {
|
||||
/// The total number of pages.
|
||||
pub fn total_pages(&self) -> usize {
|
||||
if self.total == 0 {
|
||||
return 0;
|
||||
}
|
||||
self.total.div_ceil(self.page_size)
|
||||
}
|
||||
|
||||
/// Whether there is a next page.
|
||||
pub fn has_next(&self) -> bool {
|
||||
self.page < self.total_pages()
|
||||
}
|
||||
|
||||
/// Whether there is a previous page.
|
||||
pub fn has_prev(&self) -> bool {
|
||||
self.page > 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a [`Paginated`] response by slicing `items` according to the
|
||||
/// given `page` (1-based) and `page_size`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `page == 0` or `page_size == 0`.
|
||||
pub fn paginate<T>(items: Vec<T>, page: usize, page_size: usize) -> Paginated<T> {
|
||||
assert!(page > 0, "page must be 1-based");
|
||||
assert!(page_size > 0, "page_size must be > 0");
|
||||
|
||||
let total = items.len();
|
||||
let offset = (page - 1) * page_size;
|
||||
let items = if offset >= total {
|
||||
Vec::new()
|
||||
} else {
|
||||
let end = (offset + page_size).min(total);
|
||||
items.into_iter().skip(offset).take(end - offset).collect()
|
||||
};
|
||||
|
||||
Paginated {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the SQL offset/limit from 1-based page params.
|
||||
///
|
||||
/// Returns `(offset, limit)`.
|
||||
pub fn page_params(page: usize, page_size: usize) -> (usize, usize) {
|
||||
let offset = page.saturating_sub(1) * page_size;
|
||||
(offset, page_size)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_paginate_first_page() {
|
||||
let items: Vec<i32> = (1..=25).collect();
|
||||
let result = paginate(items, 1, 10);
|
||||
assert_eq!(result.items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
assert_eq!(result.total, 25);
|
||||
assert_eq!(result.page, 1);
|
||||
assert_eq!(result.page_size, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_last_page() {
|
||||
let items: Vec<i32> = (1..=25).collect();
|
||||
let result = paginate(items, 3, 10);
|
||||
assert_eq!(result.items, vec![21, 22, 23, 24, 25]);
|
||||
assert_eq!(result.total, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_empty() {
|
||||
let items: Vec<i32> = vec![];
|
||||
let result = paginate(items, 1, 10);
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_pages() {
|
||||
let items: Vec<i32> = (1..=25).collect();
|
||||
let result = paginate(items, 1, 10);
|
||||
assert_eq!(result.total_pages(), 3);
|
||||
assert!(result.has_next());
|
||||
assert!(!result.has_prev());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_params() {
|
||||
assert_eq!(page_params(1, 20), (0, 20));
|
||||
assert_eq!(page_params(2, 20), (20, 20));
|
||||
assert_eq!(page_params(3, 20), (40, 20));
|
||||
assert_eq!(page_params(0, 20), (0, 20)); // saturating sub
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip() {
|
||||
let p: Paginated<String> = Paginated {
|
||||
items: vec!["a".into(), "b".into()],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: Paginated<String> = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.items, p.items);
|
||||
assert_eq!(back.total, p.total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
/// Characters that are invalid in filenames on most operating systems.
|
||||
const INVALID_FILENAME_CHARS: &[char] = &[
|
||||
'/', '\0', '<', '>', ':', '"', '\\', '|', '?', '*', '\x01', '\x02', '\x03', '\x04', '\x05',
|
||||
'\x06', '\x07', '\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10',
|
||||
'\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b',
|
||||
'\x1c', '\x1d', '\x1e', '\x1f', '\x7f',
|
||||
];
|
||||
|
||||
/// Replace characters that are invalid in filenames with `_`.
|
||||
///
|
||||
/// Also strips leading/trailing whitespace and dots, because those can be
|
||||
/// problematic on some filesystems.
|
||||
pub fn sanitize_filename(s: &str) -> String {
|
||||
let sanitized: String = s
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if INVALID_FILENAME_CHARS.contains(&c) {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Trim leading/trailing whitespace and dots
|
||||
let trimmed = sanitized.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return "unnamed".to_string();
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Sanitize a user-supplied path to prevent directory traversal.
|
||||
///
|
||||
/// Replaces `..` path components with `_`, collapses repeated separators,
|
||||
/// and strips any leading `/` to keep the result relative.
|
||||
pub fn sanitize_path(path: &str) -> String {
|
||||
let mut cleaned = String::new();
|
||||
|
||||
for component in path.split(&['/', '\\'][..]) {
|
||||
if component.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if component == "." {
|
||||
continue;
|
||||
}
|
||||
if component == ".." {
|
||||
if !cleaned.is_empty() {
|
||||
cleaned.push('/');
|
||||
}
|
||||
cleaned.push('_');
|
||||
} else {
|
||||
if !cleaned.is_empty() {
|
||||
cleaned.push('/');
|
||||
}
|
||||
cleaned.push_str(component);
|
||||
}
|
||||
}
|
||||
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Escape HTML special characters so the string can be safely embedded in
|
||||
/// HTML or XML content.
|
||||
pub fn sanitize_html(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max_chars` characters, appending `…` if it
|
||||
/// was truncated.
|
||||
///
|
||||
/// If `max_chars` is 0, returns an empty string. If the string is already
|
||||
/// short enough, returns it unchanged.
|
||||
pub fn truncate(s: &str, max_chars: usize) -> String {
|
||||
if max_chars == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
// Leave room for the ellipsis character
|
||||
let cutoff = max_chars.saturating_sub(1);
|
||||
let truncated: String = s.chars().take(cutoff).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// Validate that a session ID contains only alphanumeric characters, dashes,
|
||||
/// and underscores, and is non-empty.
|
||||
pub fn is_valid_session_id(id: &str) -> bool {
|
||||
if id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
id.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_replaces_invalid() {
|
||||
assert_eq!(sanitize_filename("hello/world:test"), "hello_world_test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_trims_dots() {
|
||||
assert_eq!(sanitize_filename(".hidden"), "hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_empty_fallback() {
|
||||
assert_eq!(sanitize_filename(".."), "unnamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_path_removes_dotdot() {
|
||||
assert_eq!(
|
||||
sanitize_path("foo/../../bar"),
|
||||
"foo/_/_/bar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_path_removes_dot() {
|
||||
assert_eq!(sanitize_path("./foo/./bar"), "foo/bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_path_backslash() {
|
||||
assert_eq!(sanitize_path("foo\\..\\bar"), "foo/_/bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_html_escapes() {
|
||||
assert_eq!(
|
||||
sanitize_html("<script>alert('xss')</script>"),
|
||||
"<script>alert('xss')</script>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_html_ampersand() {
|
||||
assert_eq!(sanitize_html("a & b"), "a & b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_short() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_long() {
|
||||
let result = truncate("hello world this is long", 10);
|
||||
assert_eq!(result.chars().count(), 10);
|
||||
assert!(result.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_zero() {
|
||||
assert_eq!(truncate("hello", 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_session_id() {
|
||||
assert!(is_valid_session_id("abc-123_def"));
|
||||
assert!(!is_valid_session_id("abc 123"));
|
||||
assert!(!is_valid_session_id(""));
|
||||
assert!(!is_valid_session_id("../evil"));
|
||||
}
|
||||
}
|
||||
@@ -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