//! File attachment domain types. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::id_types::{AttachmentId, EmailId, ProjectId, TaskId, UserId}; /// A file attachment linked to a task or project. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attachment { pub id: AttachmentId, pub user_id: UserId, pub task_id: Option, pub project_id: Option, pub filename: String, pub file_size: i64, pub mime_type: String, pub blob_hash: String, pub source_email_id: Option, pub created_at: DateTime, } /// Data for creating a new attachment. #[derive(Debug, Clone)] pub struct NewAttachment { pub task_id: Option, pub project_id: Option, pub filename: String, pub file_size: i64, pub mime_type: String, pub blob_hash: String, pub source_email_id: Option, } /// Metadata for an email attachment, stored as JSON in emails.attachment_meta. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttachmentMeta { pub filename: String, pub mime_type: String, pub size: usize, pub blob_hash: String, } /// Detect MIME type from file extension. pub fn mime_from_extension(filename: &str) -> &'static str { let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); match ext.as_str() { "pdf" => "application/pdf", "doc" | "docx" => "application/msword", "xls" | "xlsx" => "application/vnd.ms-excel", "ppt" | "pptx" => "application/vnd.ms-powerpoint", "zip" => "application/zip", "gz" | "gzip" => "application/gzip", "tar" => "application/x-tar", "json" => "application/json", "xml" => "application/xml", "csv" => "text/csv", "txt" | "md" | "markdown" => "text/plain", "html" | "htm" => "text/html", "css" => "text/css", "js" => "text/javascript", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "svg" => "image/svg+xml", "webp" => "image/webp", "ico" => "image/x-icon", "mp3" => "audio/mpeg", "wav" => "audio/wav", "ogg" => "audio/ogg", "flac" => "audio/flac", "aac" => "audio/aac", "mp4" => "video/mp4", "webm" => "video/webm", "mov" => "video/quicktime", "avi" => "video/x-msvideo", "rs" => "text/x-rust", "py" => "text/x-python", "toml" => "application/toml", "yaml" | "yml" => "application/yaml", _ => "application/octet-stream", } } /// Format a byte count as a human-readable string. pub fn format_file_size(bytes: i64) -> String { if bytes < 1024 { format!("{} B", bytes) } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } #[cfg(test)] mod tests { use super::*; #[test] fn mime_detection_common_types() { assert_eq!(mime_from_extension("report.pdf"), "application/pdf"); assert_eq!(mime_from_extension("photo.jpg"), "image/jpeg"); assert_eq!(mime_from_extension("photo.JPEG"), "image/jpeg"); assert_eq!(mime_from_extension("song.mp3"), "audio/mpeg"); assert_eq!(mime_from_extension("video.mp4"), "video/mp4"); assert_eq!(mime_from_extension("notes.txt"), "text/plain"); assert_eq!(mime_from_extension("data.csv"), "text/csv"); } #[test] fn mime_detection_unknown_extension() { assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream"); assert_eq!(mime_from_extension("noext"), "application/octet-stream"); } #[test] fn file_size_formatting() { assert_eq!(format_file_size(0), "0 B"); assert_eq!(format_file_size(512), "512 B"); assert_eq!(format_file_size(1024), "1.0 KB"); assert_eq!(format_file_size(1536), "1.5 KB"); assert_eq!(format_file_size(1048576), "1.0 MB"); assert_eq!(format_file_size(1572864), "1.5 MB"); assert_eq!(format_file_size(1073741824), "1.0 GB"); } #[test] fn mime_from_dotfile() { assert_eq!(mime_from_extension(".gitignore"), "application/octet-stream"); } #[test] fn attachment_meta_serialization_roundtrip() { let meta = AttachmentMeta { filename: "report.pdf".into(), mime_type: "application/pdf".into(), size: 12345, blob_hash: "abc123def456".into(), }; let json = serde_json::to_string(&meta).unwrap(); let deserialized: AttachmentMeta = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.filename, "report.pdf"); assert_eq!(deserialized.mime_type, "application/pdf"); assert_eq!(deserialized.size, 12345); assert_eq!(deserialized.blob_hash, "abc123def456"); } #[test] fn attachment_meta_empty_list() { let metas: Vec = vec![]; let json = serde_json::to_string(&metas).unwrap(); assert_eq!(json, "[]"); let deserialized: Vec = serde_json::from_str(&json).unwrap(); assert!(deserialized.is_empty()); } #[test] fn attachment_meta_multiple() { let metas = vec![ AttachmentMeta { filename: "doc.pdf".into(), mime_type: "application/pdf".into(), size: 1000, blob_hash: "hash1".into(), }, AttachmentMeta { filename: "image.png".into(), mime_type: "image/png".into(), size: 2000, blob_hash: "hash2".into(), }, ]; let json = serde_json::to_string(&metas).unwrap(); let deserialized: Vec = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.len(), 2); assert_eq!(deserialized[0].filename, "doc.pdf"); assert_eq!(deserialized[1].filename, "image.png"); } }