Skip to main content

max / goingson

6.1 KB · 183 lines History Blame Raw
1 //! File attachment domain types.
2
3 use chrono::{DateTime, Utc};
4 use serde::{Deserialize, Serialize};
5 use crate::id_types::{AttachmentId, EmailId, ProjectId, TaskId, UserId};
6
7 /// A file attachment linked to a task or project.
8 #[derive(Debug, Clone, Serialize, Deserialize)]
9 #[serde(rename_all = "camelCase")]
10 pub struct Attachment {
11 pub id: AttachmentId,
12 pub user_id: UserId,
13 pub task_id: Option<TaskId>,
14 pub project_id: Option<ProjectId>,
15 pub filename: String,
16 pub file_size: i64,
17 pub mime_type: String,
18 pub blob_hash: String,
19 pub source_email_id: Option<EmailId>,
20 pub created_at: DateTime<Utc>,
21 }
22
23 /// Data for creating a new attachment.
24 #[derive(Debug, Clone)]
25 pub struct NewAttachment {
26 pub task_id: Option<TaskId>,
27 pub project_id: Option<ProjectId>,
28 pub filename: String,
29 pub file_size: i64,
30 pub mime_type: String,
31 pub blob_hash: String,
32 pub source_email_id: Option<EmailId>,
33 }
34
35 /// Metadata for an email attachment, stored as JSON in emails.attachment_meta.
36 #[derive(Debug, Clone, Serialize, Deserialize)]
37 pub struct AttachmentMeta {
38 pub filename: String,
39 pub mime_type: String,
40 pub size: usize,
41 pub blob_hash: String,
42 }
43
44 /// Detect MIME type from file extension.
45 pub fn mime_from_extension(filename: &str) -> &'static str {
46 let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
47 match ext.as_str() {
48 "pdf" => "application/pdf",
49 "doc" | "docx" => "application/msword",
50 "xls" | "xlsx" => "application/vnd.ms-excel",
51 "ppt" | "pptx" => "application/vnd.ms-powerpoint",
52 "zip" => "application/zip",
53 "gz" | "gzip" => "application/gzip",
54 "tar" => "application/x-tar",
55 "json" => "application/json",
56 "xml" => "application/xml",
57 "csv" => "text/csv",
58 "txt" | "md" | "markdown" => "text/plain",
59 "html" | "htm" => "text/html",
60 "css" => "text/css",
61 "js" => "text/javascript",
62 "png" => "image/png",
63 "jpg" | "jpeg" => "image/jpeg",
64 "gif" => "image/gif",
65 "svg" => "image/svg+xml",
66 "webp" => "image/webp",
67 "ico" => "image/x-icon",
68 "mp3" => "audio/mpeg",
69 "wav" => "audio/wav",
70 "ogg" => "audio/ogg",
71 "flac" => "audio/flac",
72 "aac" => "audio/aac",
73 "mp4" => "video/mp4",
74 "webm" => "video/webm",
75 "mov" => "video/quicktime",
76 "avi" => "video/x-msvideo",
77 "rs" => "text/x-rust",
78 "py" => "text/x-python",
79 "toml" => "application/toml",
80 "yaml" | "yml" => "application/yaml",
81 _ => "application/octet-stream",
82 }
83 }
84
85 /// Format a byte count as a human-readable string.
86 pub fn format_file_size(bytes: i64) -> String {
87 if bytes < 1024 {
88 format!("{} B", bytes)
89 } else if bytes < 1024 * 1024 {
90 format!("{:.1} KB", bytes as f64 / 1024.0)
91 } else if bytes < 1024 * 1024 * 1024 {
92 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
93 } else {
94 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
95 }
96 }
97
98 #[cfg(test)]
99 mod tests {
100 use super::*;
101
102 #[test]
103 fn mime_detection_common_types() {
104 assert_eq!(mime_from_extension("report.pdf"), "application/pdf");
105 assert_eq!(mime_from_extension("photo.jpg"), "image/jpeg");
106 assert_eq!(mime_from_extension("photo.JPEG"), "image/jpeg");
107 assert_eq!(mime_from_extension("song.mp3"), "audio/mpeg");
108 assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
109 assert_eq!(mime_from_extension("notes.txt"), "text/plain");
110 assert_eq!(mime_from_extension("data.csv"), "text/csv");
111 }
112
113 #[test]
114 fn mime_detection_unknown_extension() {
115 assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
116 assert_eq!(mime_from_extension("noext"), "application/octet-stream");
117 }
118
119 #[test]
120 fn file_size_formatting() {
121 assert_eq!(format_file_size(0), "0 B");
122 assert_eq!(format_file_size(512), "512 B");
123 assert_eq!(format_file_size(1024), "1.0 KB");
124 assert_eq!(format_file_size(1536), "1.5 KB");
125 assert_eq!(format_file_size(1048576), "1.0 MB");
126 assert_eq!(format_file_size(1572864), "1.5 MB");
127 assert_eq!(format_file_size(1073741824), "1.0 GB");
128 }
129
130 #[test]
131 fn mime_from_dotfile() {
132 assert_eq!(mime_from_extension(".gitignore"), "application/octet-stream");
133 }
134
135 #[test]
136 fn attachment_meta_serialization_roundtrip() {
137 let meta = AttachmentMeta {
138 filename: "report.pdf".into(),
139 mime_type: "application/pdf".into(),
140 size: 12345,
141 blob_hash: "abc123def456".into(),
142 };
143 let json = serde_json::to_string(&meta).unwrap();
144 let deserialized: AttachmentMeta = serde_json::from_str(&json).unwrap();
145 assert_eq!(deserialized.filename, "report.pdf");
146 assert_eq!(deserialized.mime_type, "application/pdf");
147 assert_eq!(deserialized.size, 12345);
148 assert_eq!(deserialized.blob_hash, "abc123def456");
149 }
150
151 #[test]
152 fn attachment_meta_empty_list() {
153 let metas: Vec<AttachmentMeta> = vec![];
154 let json = serde_json::to_string(&metas).unwrap();
155 assert_eq!(json, "[]");
156 let deserialized: Vec<AttachmentMeta> = serde_json::from_str(&json).unwrap();
157 assert!(deserialized.is_empty());
158 }
159
160 #[test]
161 fn attachment_meta_multiple() {
162 let metas = vec![
163 AttachmentMeta {
164 filename: "doc.pdf".into(),
165 mime_type: "application/pdf".into(),
166 size: 1000,
167 blob_hash: "hash1".into(),
168 },
169 AttachmentMeta {
170 filename: "image.png".into(),
171 mime_type: "image/png".into(),
172 size: 2000,
173 blob_hash: "hash2".into(),
174 },
175 ];
176 let json = serde_json::to_string(&metas).unwrap();
177 let deserialized: Vec<AttachmentMeta> = serde_json::from_str(&json).unwrap();
178 assert_eq!(deserialized.len(), 2);
179 assert_eq!(deserialized[0].filename, "doc.pdf");
180 assert_eq!(deserialized[1].filename, "image.png");
181 }
182 }
183