提交qiming-mcp-proxy

This commit is contained in:
Codex
2026-06-01 13:03:20 +08:00
parent 9e9486b7c2
commit afb3d9f4e6
394 changed files with 124494 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
use std::path::Path;
use tracing::{info, warn};
/// Perform global cleanup operations during shutdown
pub async fn perform_shutdown_cleanup() {
info!("Starting shutdown cleanup operations");
// Clean up any remaining temporary files
cleanup_temp_directories().await;
// Flush any remaining logs
flush_logs().await;
info!("Shutdown cleanup completed");
}
/// Clean up temporary directories and files
async fn cleanup_temp_directories() {
let temp_patterns = [
"/tmp/voice-cli-*",
"./temp/voice-cli-*",
"./tmp/voice-cli-*",
];
for pattern in &temp_patterns {
if let Some(parent) = Path::new(pattern).parent() {
if parent.exists() {
match std::fs::read_dir(parent) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with("voice-cli-") {
if path.is_file() {
if let Err(e) = std::fs::remove_file(&path) {
warn!("Failed to cleanup temp file {:?}: {}", path, e);
} else {
info!("Cleaned up temp file: {:?}", path);
}
} else if path.is_dir() {
if let Err(e) = std::fs::remove_dir_all(&path) {
warn!(
"Failed to cleanup temp directory {:?}: {}",
path, e
);
} else {
info!("Cleaned up temp directory: {:?}", path);
}
}
}
}
}
}
Err(e) => {
warn!("Failed to read temp directory {:?}: {}", parent, e);
}
}
}
}
}
}
/// Flush any remaining logs
async fn flush_logs() {
// Give the logging system a moment to flush any remaining logs
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Force flush tracing subscriber if possible
// Note: This is a best-effort operation
info!("Log flush completed");
}
/// Clean up specific temporary files
pub fn cleanup_files(files: &[std::path::PathBuf]) {
for file in files {
if file.exists() {
if let Err(e) = std::fs::remove_file(file) {
warn!("Failed to cleanup file {:?}: {}", file, e);
} else {
info!("Cleaned up file: {:?}", file);
}
}
}
}
/// Clean up a specific directory and all its contents
pub fn cleanup_directory<P: AsRef<Path>>(dir: P) -> Result<(), std::io::Error> {
let dir = dir.as_ref();
if dir.exists() && dir.is_dir() {
std::fs::remove_dir_all(dir)?;
info!("Cleaned up directory: {:?}", dir);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[tokio::test]
async fn test_cleanup_files() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("test1.txt");
let file2 = temp_dir.path().join("test2.txt");
// Create test files
File::create(&file1).unwrap();
File::create(&file2).unwrap();
assert!(file1.exists());
assert!(file2.exists());
// Cleanup files
cleanup_files(&[file1.clone(), file2.clone()]);
assert!(!file1.exists());
assert!(!file2.exists());
}
#[test]
fn test_cleanup_directory() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test_cleanup");
std::fs::create_dir(&test_dir).unwrap();
// Create a file in the directory
let test_file = test_dir.join("test.txt");
File::create(&test_file).unwrap();
assert!(test_dir.exists());
assert!(test_file.exists());
// Cleanup directory
cleanup_directory(&test_dir).unwrap();
assert!(!test_dir.exists());
assert!(!test_file.exists());
}
#[tokio::test]
async fn test_perform_shutdown_cleanup() {
// This test just ensures the function runs without panicking
perform_shutdown_cleanup().await;
}
}

View File

@@ -0,0 +1,253 @@
use std::path::Path;
use url::Url;
/// MIME 类型到文件扩展名的映射
/// 专注于音视频格式,其他类型统一使用 bin 扩展名
pub fn mime_type_to_extension(content_type: &str) -> &'static str {
match content_type {
// 音频类型
"audio/mpeg" => "mp3",
"audio/wav" => "wav",
"audio/flac" => "flac",
"audio/mp4" => "m4a",
"audio/ogg" => "ogg",
"audio/aac" => "aac",
"audio/opus" => "opus",
"audio/webm" => "webm",
"audio/x-matroska" => "mka",
"audio/x-ms-wma" => "wma",
"audio/x-wav" => "wav",
"audio/vnd.wave" => "wav",
"audio/x-aiff" => "aiff",
"audio/aiff" => "aiff",
"audio/x-caf" => "caf",
"audio/x-m4a" => "m4a",
"audio/x-mpeg" => "mp3",
"audio/x-ogg" => "ogg",
// 视频类型
"video/mp4" => "mp4",
"video/webm" => "webm",
"video/x-matroska" => "mkv",
"video/x-msvideo" => "avi",
"video/quicktime" => "mov",
"video/x-ms-wmv" => "wmv",
"video/x-flv" => "flv",
"video/3gpp" => "3gp",
"video/3gpp2" => "3g2",
"video/mpeg" => "mpeg",
"video/x-mpeg" => "mpeg",
// 其他所有类型统一使用 bin 扩展名
_ => {
// 对于非音视频类型,直接使用默认扩展名,不输出警告日志
"bin"
}
}
}
/// 从 URL 中提取文件扩展名
pub fn extract_extension_from_url(url: &str) -> Option<&'static str> {
if let Ok(parsed_url) = Url::parse(url) {
parsed_url
.path_segments()
.and_then(|segments| segments.last())
.and_then(|filename| {
if let Some(dot_index) = filename.rfind('.') {
let ext = &filename[dot_index + 1..];
// 将常见扩展名映射为标准格式
match ext.to_lowercase().as_str() {
"mp3" => Some("mp3"),
"wav" => Some("wav"),
"flac" => Some("flac"),
"m4a" => Some("m4a"),
"mp4" => Some("mp4"),
"mov" => Some("mov"),
"avi" => Some("avi"),
"mkv" => Some("mkv"),
"webm" => Some("webm"),
"ogg" => Some("ogg"),
"aac" => Some("aac"),
"opus" => Some("opus"),
"wma" => Some("wma"),
"flv" => Some("flv"),
"3gp" => Some("3gp"),
"caf" => Some("caf"),
"aiff" => Some("aiff"),
"bin" => Some("bin"),
_ => None,
}
} else {
None
}
})
} else {
None
}
}
/// 从文件路径推断 MIME 类型
pub fn infer_mime_type_from_path<P: AsRef<Path>>(path: P) -> Option<&'static str> {
infer::get_from_path(path)
.ok()
.flatten()
.map(|kind| kind.mime_type())
}
/// 从文件扩展名获取 MIME 类型
pub fn extension_to_mime_type(extension: &str) -> &'static str {
match extension.to_lowercase().as_str() {
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"m4a" => "audio/mp4",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
"ogg" => "audio/ogg",
"aac" => "audio/aac",
"opus" => "audio/opus",
"wma" => "audio/x-ms-wma",
"flv" => "video/x-flv",
"3gp" => "video/3gpp",
"caf" => "audio/x-caf",
"aiff" => "audio/x-aiff",
"bin" => "application/octet-stream",
_ => "application/octet-stream",
}
}
/// 智能获取文件扩展名(优先使用 MIME 类型,回退到 URL 扩展名)
pub fn get_file_extension(content_type: &str, url: &str) -> &'static str {
let extension = mime_type_to_extension(content_type);
// 如果得到的是默认扩展名,尝试从 URL 中获取更精确的扩展名
if extension == "bin" {
if let Some(url_extension) = extract_extension_from_url(url) {
return url_extension;
}
}
extension
}
/// 检查是否为支持的音频格式
pub fn is_supported_audio_format(mime_type: &str) -> bool {
matches!(
mime_type,
"audio/mpeg" | // MP3
"audio/wav" | // WAV
"audio/flac" | // FLAC
"audio/mp4" | // M4A
"audio/ogg" | // OGG
"audio/aac" | // AAC
"audio/opus" | // Opus
"audio/webm" | // WebM Audio
"audio/x-matroska" | // Matroska Audio
"audio/x-wav" | // WAV (alternative)
"audio/vnd.wave" | // WAV (alternative)
"audio/x-aiff" | // AIFF
"audio/aiff" | // AIFF (alternative)
"audio/x-caf" // CAF
)
}
/// 检查是否为支持的视频格式
pub fn is_supported_video_format(mime_type: &str) -> bool {
matches!(
mime_type,
"video/mp4" | // MP4
"video/webm" | // WebM
"video/x-matroska" | // MKV
"video/x-msvideo" | // AVI
"video/quicktime" | // MOV
"video/x-ms-wmv" | // WMV
"video/x-flv" | // FLV
"video/3gpp" | // 3GP
"video/3gpp2" | // 3G2
"video/mpeg" | // MPEG
"video/x-mpeg" // MPEG (alternative)
)
}
/// 检查是否为支持的媒体格式
pub fn is_supported_media_format(mime_type: &str) -> bool {
is_supported_audio_format(mime_type) || is_supported_video_format(mime_type)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_type_to_extension() {
assert_eq!(mime_type_to_extension("audio/mpeg"), "mp3");
assert_eq!(mime_type_to_extension("audio/wav"), "wav");
assert_eq!(mime_type_to_extension("video/mp4"), "mp4");
assert_eq!(mime_type_to_extension("unknown/type"), "bin");
assert_eq!(mime_type_to_extension("text/plain"), "bin");
assert_eq!(mime_type_to_extension("application/json"), "bin");
assert_eq!(mime_type_to_extension("image/jpeg"), "bin");
}
#[test]
fn test_extract_extension_from_url() {
assert_eq!(
extract_extension_from_url("https://example.com/test.mp3"),
Some("mp3")
);
assert_eq!(
extract_extension_from_url("https://example.com/test.wav"),
Some("wav")
);
assert_eq!(
extract_extension_from_url("https://example.com/test?format=mp3"),
None
);
assert_eq!(extract_extension_from_url("invalid-url"), None);
}
#[test]
fn test_extension_to_mime_type() {
assert_eq!(extension_to_mime_type("mp3"), "audio/mpeg");
assert_eq!(extension_to_mime_type("wav"), "audio/wav");
assert_eq!(extension_to_mime_type("mp4"), "video/mp4");
assert_eq!(
extension_to_mime_type("unknown"),
"application/octet-stream"
);
}
#[test]
fn test_get_file_extension() {
assert_eq!(
get_file_extension("audio/mpeg", "https://example.com/test.mp3"),
"mp3"
);
assert_eq!(
get_file_extension("unknown/type", "https://example.com/test.wav"),
"wav"
);
assert_eq!(
get_file_extension("unknown/type", "https://example.com/test"),
"bin"
);
}
#[test]
fn test_supported_formats() {
assert!(is_supported_audio_format("audio/mpeg"));
assert!(is_supported_audio_format("audio/wav"));
assert!(!is_supported_audio_format("video/mp4"));
assert!(is_supported_video_format("video/mp4"));
assert!(is_supported_video_format("video/webm"));
assert!(!is_supported_video_format("audio/mpeg"));
assert!(is_supported_media_format("audio/mpeg"));
assert!(is_supported_media_format("video/mp4"));
assert!(!is_supported_media_format("unknown/type"));
}
}

View File

@@ -0,0 +1,229 @@
pub mod cleanup;
pub mod mime_types;
pub mod signal_handling;
pub mod task_id;
use crate::VoiceCliError;
use crate::models::Config;
use std::path::PathBuf;
use tracing::{Level, info};
use tracing_appender::rolling::{Builder, Rotation};
use tracing_subscriber::{EnvFilter, prelude::*};
// Re-export signal handling components
pub use cleanup::perform_shutdown_cleanup;
pub use mime_types::{
extension_to_mime_type, extract_extension_from_url, get_file_extension,
infer_mime_type_from_path, is_supported_audio_format, is_supported_media_format,
is_supported_video_format, mime_type_to_extension,
};
pub use signal_handling::{
create_combined_shutdown_signal, create_service_shutdown_signal, create_shutdown_signal,
handle_system_signals,
};
pub use task_id::generate_task_id;
/// Initialize logging based on configuration
/// The guard is stored globally to ensure logging stays active
pub fn init_logging(config: &Config) -> crate::Result<()> {
// Check if logging is already initialized
if tracing::dispatcher::has_been_set() {
// Reset logging to allow proper file logging setup
// Use the proper method to clear the global dispatcher
tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::default()).ok();
}
// Create logs directory if it doesn't exist
let log_dir = config.log_dir_path();
std::fs::create_dir_all(&log_dir)?;
// Parse log level
let level = parse_log_level(&config.logging.level)?;
// Create file appender with rotation and max_log_files
let file_appender = Builder::new()
.rotation(Rotation::DAILY)
.filename_prefix("voice-cli")
.max_log_files(config.logging.max_files as usize)
.build(&log_dir)
.map_err(|e| {
VoiceCliError::Config(format!("Failed to initialize log file appender: {}", e))
})?;
// Create console layer with proper formatting
let console_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.with_ansi(true);
// Create file layer with detailed formatting
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(file_appender)
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.with_ansi(false);
// Create filters based on configured log level
let console_filter = EnvFilter::new(&format!("{}", level));
let file_filter = EnvFilter::new(&format!("{}", level));
// Create the subscriber with both layers
let subscriber = tracing_subscriber::registry()
.with(console_layer.with_filter(console_filter))
.with(file_layer.with_filter(file_filter));
// Set as global default and get the guard
let _ = tracing::subscriber::set_global_default(subscriber)
.map_err(|e| VoiceCliError::Config(format!("Failed to set global subscriber: {}", e)))?;
// Store the guard globally to keep logging active
info!(
"Logging initialized - Level: {}, Directory: {:?}",
config.logging.level, log_dir
);
Ok(())
}
/// Parse log level string to tracing Level
fn parse_log_level(level_str: &str) -> crate::Result<Level> {
match level_str.to_lowercase().as_str() {
"trace" => Ok(Level::TRACE),
"debug" => Ok(Level::DEBUG),
"info" => Ok(Level::INFO),
"warn" | "warning" => Ok(Level::WARN),
"error" => Ok(Level::ERROR),
_ => Err(VoiceCliError::Config(format!(
"Invalid log level: {}. Valid levels: trace, debug, info, warn, error",
level_str
))),
}
}
/// Clean up old log files based on configuration
pub fn cleanup_old_logs(config: &Config) -> crate::Result<()> {
let log_dir = config.log_dir_path();
if !log_dir.exists() {
return Ok(());
}
let max_files = config.logging.max_files as usize;
// Get all log files
let mut log_files: Vec<_> = std::fs::read_dir(&log_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "log")
.unwrap_or(false)
})
.collect();
// Sort by modification time (newest first)
log_files.sort_by_key(|entry| {
entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
log_files.reverse();
// Remove old files
if log_files.len() > max_files {
for old_file in log_files.iter().skip(max_files) {
if let Err(e) = std::fs::remove_file(old_file.path()) {
tracing::warn!("Failed to remove old log file {:?}: {}", old_file.path(), e);
} else {
tracing::debug!("Removed old log file: {:?}", old_file.path());
}
}
}
Ok(())
}
/// Get the current executable path
pub fn get_current_exe_path() -> crate::Result<PathBuf> {
std::env::current_exe()
.map_err(|e| VoiceCliError::Config(format!("Failed to get current executable path: {}", e)))
}
/// Check if a port is available
pub fn is_port_available(host: &str, port: u16) -> bool {
match std::net::TcpListener::bind(format!("{}:{}", host, port)) {
Ok(_) => true,
Err(_) => false,
}
}
/// Create a safe filename from a string
pub fn safe_filename(input: &str) -> String {
input
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
_ => '_',
})
.collect()
}
/// Get system information for debugging
pub fn get_system_info() -> SystemInfo {
SystemInfo {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
family: std::env::consts::FAMILY.to_string(),
exe_suffix: std::env::consts::EXE_SUFFIX.to_string(),
}
}
#[derive(Debug)]
pub struct SystemInfo {
pub os: String,
pub arch: String,
pub family: String,
pub exe_suffix: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_log_level() {
assert!(matches!(parse_log_level("info"), Ok(Level::INFO)));
assert!(matches!(parse_log_level("INFO"), Ok(Level::INFO)));
assert!(matches!(parse_log_level("debug"), Ok(Level::DEBUG)));
assert!(matches!(parse_log_level("error"), Ok(Level::ERROR)));
assert!(parse_log_level("invalid").is_err());
}
#[test]
fn test_safe_filename() {
assert_eq!(safe_filename("hello world"), "hello_world");
assert_eq!(safe_filename("test-file.mp3"), "test-file.mp3");
assert_eq!(safe_filename("special@#$chars"), "special___chars");
}
#[test]
fn test_is_port_available() {
// Test with a likely available port
assert!(is_port_available("127.0.0.1", 0)); // Port 0 should always be available for testing
}
#[test]
fn test_get_system_info() {
let info = get_system_info();
assert!(!info.os.is_empty());
assert!(!info.arch.is_empty());
}
}

View File

@@ -0,0 +1,206 @@
//! Shared signal handling utilities
//!
//! This module provides unified signal handling for background services,
//! eliminating duplicate signal handling code across different services.
use tokio::signal;
use tracing::{debug, error, info};
/// Create a unified shutdown signal handler that listens to multiple sources
///
/// This function provides consistent signal handling across all services:
/// - Ctrl+C (SIGINT)
/// - SIGTERM (on Unix platforms)
/// - Manual shutdown signals
///
/// # Example
/// ```rust,no_run
/// use voice_cli::utils::signal_handling::create_shutdown_signal;
/// use tracing::info;
///
/// # async fn example() {
/// let shutdown_signal = create_shutdown_signal();
/// tokio::select! {
/// _ = async { /* service_work */ } => {},
/// _ = shutdown_signal => {
/// info!("Received shutdown signal, stopping service");
/// }
/// }
/// # }
/// ```
pub async fn create_shutdown_signal() {
handle_system_signals().await
}
/// Handle system signals for graceful shutdown
///
/// This provides the core signal handling logic used by all services.
/// Separated into its own function for reusability and testing.
pub async fn handle_system_signals() {
let ctrl_c = async {
if let Err(e) = signal::ctrl_c().await {
error!("Failed to listen for Ctrl+C: {}", e);
} else {
info!("Received Ctrl+C signal");
}
};
#[cfg(unix)]
let terminate = async {
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut stream) => {
stream.recv().await;
info!("Received SIGTERM signal");
}
Err(e) => {
error!("Failed to listen for SIGTERM: {}", e);
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => debug!("Ctrl+C signal handled"),
_ = terminate => debug!("SIGTERM signal handled"),
}
}
/// Create a combined shutdown signal that listens to both system signals and manual channels
///
/// This is useful for services that need to respond to both system signals (Ctrl+C, SIGTERM)
/// and programmatic shutdown requests via channels.
///
/// # Arguments
/// * `manual_shutdown` - A future that completes when manual shutdown is requested
///
/// # Example
/// ```rust,no_run
/// use voice_cli::utils::signal_handling::create_combined_shutdown_signal;
/// use tracing::info;
///
/// # async fn example() {
/// let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
/// let shutdown_signal = create_combined_shutdown_signal(async {
/// shutdown_rx.await.ok();
/// });
///
/// tokio::select! {
/// _ = async { /* service_work */ } => {},
/// _ = shutdown_signal => {
/// info!("Received shutdown signal");
/// }
/// }
/// # }
/// ```
pub async fn create_combined_shutdown_signal<F>(manual_shutdown: F)
where
F: std::future::Future<Output = ()>,
{
tokio::select! {
_ = handle_system_signals() => {
info!("Received system shutdown signal");
}
_ = manual_shutdown => {
info!("Received manual shutdown signal");
}
}
}
/// Create a shutdown signal with service-specific logging
///
/// This provides service-specific logging messages for better debugging.
///
/// # Arguments
/// * `service_name` - Name of the service for logging context
///
/// # Example
/// ```rust,no_run
/// use voice_cli::utils::signal_handling::create_service_shutdown_signal;
///
/// # async fn example() {
/// let shutdown_signal = create_service_shutdown_signal("http-server");
/// tokio::select! {
/// _ = async { /* service_work */ } => {},
/// _ = shutdown_signal => {}
/// }
/// # }
/// ```
pub async fn create_service_shutdown_signal(service_name: &str) {
let ctrl_c = async {
if let Err(e) = signal::ctrl_c().await {
error!("Failed to listen for Ctrl+C in {}: {}", service_name, e);
} else {
info!("{} received Ctrl+C signal", service_name);
}
};
#[cfg(unix)]
let terminate = async {
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut stream) => {
stream.recv().await;
info!("{} received SIGTERM signal", service_name);
}
Err(e) => {
error!("Failed to listen for SIGTERM in {}: {}", service_name, e);
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => debug!("{} handled Ctrl+C signal", service_name),
_ = terminate => debug!("{} handled SIGTERM signal", service_name),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::{Duration, timeout};
#[tokio::test]
async fn test_signal_handling_timeout() {
// Test that the signal handlers don't hang indefinitely
let result = timeout(Duration::from_millis(100), create_shutdown_signal()).await;
// Should timeout since no signals are sent
assert!(result.is_err());
}
#[tokio::test]
async fn test_combined_shutdown_manual() {
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
// Start the combined signal handler
let signal_future = create_combined_shutdown_signal(async move {
rx.await.ok();
});
// Send manual shutdown signal
let _ = tx.send(());
// Should complete quickly due to manual signal
let result = timeout(Duration::from_millis(100), signal_future).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_service_shutdown_signal_timeout() {
// Test service-specific signal handling
let result = timeout(
Duration::from_millis(100),
create_service_shutdown_signal("test-service"),
)
.await;
// Should timeout since no signals are sent
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,54 @@
/// Task ID generation utilities
use uuid::Uuid;
/// Generate a clean task ID with only alphanumeric characters
/// Format: "task" + 32-character hexadecimal string (from UUID v7)
///
/// # Examples
///
/// ```
/// use voice_cli::utils::task_id::generate_task_id;
///
/// let task_id = generate_task_id();
/// assert!(task_id.starts_with("task"));
/// assert!(!task_id.contains('-'));
/// assert!(!task_id.contains('_'));
/// assert_eq!(task_id.len(), 36); // "task" + 32 hex chars
/// ```
pub fn generate_task_id() -> String {
let uuid = Uuid::now_v7().to_string();
// 移除所有连字符和下划线,只保留字母和数字
let cleaned_uuid = uuid.replace(['-', '_'], "");
format!("task{}", cleaned_uuid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_task_id_format() {
let task_id = generate_task_id();
// Check format
assert!(task_id.starts_with("task"));
assert!(!task_id.contains('-'));
assert!(!task_id.contains('_'));
// Check length (task + 32 hex chars)
assert_eq!(task_id.len(), 36);
// Check that it's all alphanumeric after "task" prefix
let suffix = &task_id[4..];
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_generate_task_id_uniqueness() {
let id1 = generate_task_id();
let id2 = generate_task_id();
// Should be different (due to timestamp in UUID v7)
assert_ne!(id1, id2);
}
}