//! ID generation utilities (enabled with "ids" feature) #[cfg(feature = "ids")] use uuid::Uuid; /// UUID utilities #[cfg(feature = "ids")] pub mod uuid { use super::*; /// Generate new UUID v4 pub fn generate() -> String { Uuid::new_v4().to_string() } /// Generate UUID v4 in simple format (no hyphens) pub fn generate_simple() -> String { Uuid::new_v4().simple().to_string() } /// Generate short ID (first 8 characters of UUID) pub fn generate_short() -> String { Uuid::new_v4().simple().to_string()[..8].to_string() } /// Generate very short ID (first 4 characters of UUID) pub fn generate_tiny() -> String { Uuid::new_v4().simple().to_string()[..4].to_string() } /// Validate UUID string pub fn is_valid(id: &str) -> bool { Uuid::parse_str(id).is_ok() } /// Parse UUID from string pub fn parse(id: &str) -> Result { Uuid::parse_str(id) } /// Convert UUID to different formats pub fn to_simple(id: &str) -> Result { let uuid = Uuid::parse_str(id)?; Ok(uuid.simple().to_string()) } /// Convert simple UUID back to hyphenated format pub fn to_hyphenated(simple_id: &str) -> Result { if simple_id.len() != 32 { return Err(uuid::Error::InvalidLength(simple_id.len())); } let formatted = format!( "{}-{}-{}-{}-{}", &simple_id[0..8], &simple_id[8..12], &simple_id[12..16], &simple_id[16..20], &simple_id[20..32] ); Uuid::parse_str(&formatted)?; Ok(formatted) } } /// Slug utilities for human-readable IDs pub mod slug { use crate::utils::string; /// Generate slug from title pub fn from_title(title: &str) -> String { string::to_kebab_case(title) .chars() .filter(|c| c.is_alphanumeric() || *c == '-') .collect::() .trim_matches('-') .to_string() } /// Generate unique slug by adding suffix if needed pub fn make_unique(base_slug: &str, exists_fn: F) -> String where F: Fn(&str) -> bool, { if !exists_fn(base_slug) { return base_slug.to_string(); } for i in 1..1000 { let candidate = format!("{}-{}", base_slug, i); if !exists_fn(&candidate) { return candidate; } } // Fallback: add UUID suffix #[cfg(feature = "ids")] { format!("{}-{}", base_slug, uuid::generate_short()) } #[cfg(not(feature = "ids"))] { use std::time::{SystemTime, UNIX_EPOCH}; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); format!("{}-{}", base_slug, timestamp) } } /// Validate slug format pub fn is_valid(slug: &str) -> bool { !slug.is_empty() && slug.len() <= 100 && slug.chars().all(|c| c.is_alphanumeric() || c == '-') && !slug.starts_with('-') && !slug.ends_with('-') && !slug.contains("--") } /// Clean up slug to make it valid pub fn clean(input: &str) -> String { let mut cleaned = from_title(input); // Remove multiple consecutive hyphens while cleaned.contains("--") { cleaned = cleaned.replace("--", "-"); } // Trim hyphens from start and end cleaned = cleaned.trim_matches('-').to_string(); // Truncate if too long if cleaned.len() > 100 { cleaned = cleaned[..100].trim_end_matches('-').to_string(); } // Ensure not empty if cleaned.is_empty() { cleaned = "untitled".to_string(); } cleaned } } /// Content ID utilities pub mod content { use super::*; /// Generate content ID from type and title pub fn generate_id(content_type: &str, title: &str) -> String { let slug = slug::clean(title); format!("{}:{}", content_type, slug) } /// Generate content ID with timestamp pub fn generate_id_with_timestamp(content_type: &str, title: &str) -> String { let slug = slug::clean(title); #[cfg(feature = "time")] { use crate::utils::time; let timestamp = time::now_iso(); let date_part = timestamp.split('T').next().unwrap_or("unknown"); format!("{}:{}:{}", content_type, date_part, slug) } #[cfg(not(feature = "time"))] { use std::time::{SystemTime, UNIX_EPOCH}; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); format!("{}:{}:{}", content_type, timestamp, slug) } } /// Parse content ID into parts pub fn parse_id(id: &str) -> Option<(String, String)> { let parts: Vec<&str> = id.splitn(2, ':').collect(); if parts.len() == 2 { Some((parts[0].to_string(), parts[1].to_string())) } else { None } } /// Validate content ID format pub fn is_valid_id(id: &str) -> bool { if let Some((content_type, slug_part)) = parse_id(id) { !content_type.is_empty() && !slug_part.is_empty() && content_type.chars().all(|c| c.is_alphanumeric() || c == '_') && slug::is_valid(&slug_part.split(':').last().unwrap_or("")) } else { false } } } // No-op implementations when ids feature is not enabled #[cfg(not(feature = "ids"))] pub mod uuid { pub fn generate() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); format!("id-{}", timestamp) } pub fn generate_simple() -> String { generate() } pub fn generate_short() -> String { generate()[..8].to_string() } pub fn generate_tiny() -> String { generate()[..4].to_string() } pub fn is_valid(_id: &str) -> bool { false } } #[cfg(test)] mod tests { use super::*; #[test] fn test_slug_generation() { assert_eq!(slug::from_title("Hello World"), "hello-world"); assert_eq!(slug::from_title("My Blog Post!"), "my-blog-post"); assert_eq!(slug::from_title("CamelCase Title"), "camel-case-title"); assert_eq!(slug::from_title("Test Spaces"), "test-spaces"); } #[test] fn test_slug_validation() { assert!(slug::is_valid("hello-world")); assert!(slug::is_valid("my-post-123")); assert!(!slug::is_valid("")); assert!(!slug::is_valid("-invalid")); assert!(!slug::is_valid("invalid-")); assert!(!slug::is_valid("double--hyphen")); assert!(!slug::is_valid("with spaces")); assert!(!slug::is_valid("with/slash")); } #[test] fn test_slug_cleaning() { assert_eq!(slug::clean("--bad--start--"), "bad-start"); assert_eq!(slug::clean(""), "untitled"); assert_eq!(slug::clean("Good Title"), "good-title"); // Test truncation let long_title = "a".repeat(150); let cleaned = slug::clean(&long_title); assert!(cleaned.len() <= 100); assert!(!cleaned.ends_with('-')); } #[test] fn test_content_id_generation() { let id = content::generate_id("blog", "My First Post"); assert_eq!(id, "blog:my-first-post"); let id_with_timestamp = content::generate_id_with_timestamp("blog", "My Post"); assert!(id_with_timestamp.starts_with("blog:")); assert!(id_with_timestamp.ends_with(":my-post")); } #[test] fn test_content_id_parsing() { let (content_type, slug) = content::parse_id("blog:my-post").unwrap(); assert_eq!(content_type, "blog"); assert_eq!(slug, "my-post"); let (content_type, slug) = content::parse_id("blog:2023-01-01:my-post").unwrap(); assert_eq!(content_type, "blog"); assert_eq!(slug, "2023-01-01:my-post"); assert!(content::parse_id("invalid-id").is_none()); } #[test] fn test_content_id_validation() { assert!(content::is_valid_id("blog:my-post")); assert!(content::is_valid_id("news_article:breaking-news")); assert!(!content::is_valid_id("")); assert!(!content::is_valid_id("blog")); assert!(!content::is_valid_id(":my-post")); assert!(!content::is_valid_id("blog:")); } #[cfg(feature = "ids")] #[test] fn test_uuid_generation() { let id = uuid::generate(); assert!(uuid::is_valid(&id)); assert_eq!(id.len(), 36); // Standard UUID format with hyphens let simple_id = uuid::generate_simple(); assert_eq!(simple_id.len(), 32); // No hyphens let short_id = uuid::generate_short(); assert_eq!(short_id.len(), 8); let tiny_id = uuid::generate_tiny(); assert_eq!(tiny_id.len(), 4); } #[cfg(feature = "ids")] #[test] fn test_uuid_conversion() { let id = uuid::generate(); let simple = uuid::to_simple(&id).unwrap(); let back_to_hyphenated = uuid::to_hyphenated(&simple).unwrap(); assert_eq!(id.to_lowercase(), back_to_hyphenated.to_lowercase()); } }