1use chrono::{DateTime, Utc};
4use rskit_errors::AppResult;
5use rskit_fs::async_io::file;
6use serde::{Deserialize, Serialize};
7
8use crate::FileSource;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FileMeta {
13 pub name: Option<String>,
15 pub extension: Option<String>,
17 pub mime_type: String,
19 pub size: Option<u64>,
21 pub created_at: Option<DateTime<Utc>>,
23 pub modified_at: Option<DateTime<Utc>>,
25 pub checksum: Option<String>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub enum FileKind {
32 Video,
34 Audio,
36 Image,
38 Document,
40 Archive,
42 Text,
44 Binary,
46 Unknown,
48}
49
50impl FileKind {
51 pub fn from_mime(mime: &str) -> Self {
53 let main = mime.split('/').next().unwrap_or("");
54 match main {
55 "video" => Self::Video,
56 "audio" => Self::Audio,
57 "image" => Self::Image,
58 "text" => Self::Text,
59 "application" => {
60 if mime.contains("pdf")
61 || mime.contains("document")
62 || mime.contains("msword")
63 || mime.contains("spreadsheet")
64 || mime.contains("presentation")
65 {
66 Self::Document
67 } else if mime.contains("zip")
68 || mime.contains("tar")
69 || mime.contains("gzip")
70 || mime.contains("bzip")
71 || mime.contains("rar")
72 || mime.contains("7z")
73 || mime.contains("xz")
74 {
75 Self::Archive
76 } else if mime.contains("json")
77 || mime.contains("xml")
78 || mime.contains("yaml")
79 || mime.contains("javascript")
80 {
81 Self::Text
82 } else {
83 Self::Binary
84 }
85 }
86 _ => Self::Unknown,
87 }
88 }
89}
90
91pub async fn detect_mime(source: &FileSource) -> AppResult<String> {
95 let bytes = match source {
97 FileSource::Bytes(b) => Some(b.clone()),
98 FileSource::Path(_) | FileSource::Temp(_) => source.read_all().await.ok(),
99 FileSource::Url(_) => None,
100 };
101
102 if let Some(bytes) = &bytes
103 && let Some(kind) = infer::get(bytes)
104 {
105 return Ok(kind.mime_type().to_string());
106 }
107
108 if let Some(ext) = source.extension() {
110 let guess = mime_guess::from_ext(ext).first_or_octet_stream();
111 return Ok(guess.to_string());
112 }
113
114 Ok("application/octet-stream".to_string())
115}
116
117pub async fn detect_kind(source: &FileSource) -> AppResult<FileKind> {
119 let mime = detect_mime(source).await?;
120 Ok(FileKind::from_mime(&mime))
121}
122
123pub async fn file_meta(source: &FileSource) -> AppResult<FileMeta> {
125 let mime_type = detect_mime(source).await?;
126 let size = source.size().await?;
127
128 let (name, extension, created_at, modified_at) = match source {
129 FileSource::Path(p) => {
130 let name = p.file_name().map(|n| n.to_string_lossy().to_string());
131 let ext = p.extension().map(|e| e.to_string_lossy().to_string());
132 let (created, modified) = match file::metadata(p).await {
133 Ok(meta) => {
134 let created = meta.created.map(DateTime::<Utc>::from);
135 let modified = meta.modified.map(DateTime::<Utc>::from);
136 (created, modified)
137 }
138 Err(_) => (None, None),
139 };
140 (name, ext, created, modified)
141 }
142 FileSource::Url(url) => {
143 let path_part = url.split('?').next().unwrap_or(url);
144 let name = path_part.rsplit('/').next().map(String::from);
145 let ext = source.extension().map(String::from);
146 (name, ext, None, None)
147 }
148 FileSource::Temp(t) => {
149 let name = t
150 .path()
151 .file_name()
152 .map(|n| n.to_string_lossy().to_string());
153 let ext = t
154 .path()
155 .extension()
156 .map(|e| e.to_string_lossy().to_string());
157 (name, ext, None, None)
158 }
159 FileSource::Bytes(_) => (None, None, None, None),
160 };
161
162 Ok(FileMeta {
163 name,
164 extension,
165 mime_type,
166 size,
167 created_at,
168 modified_at,
169 checksum: None,
170 })
171}
172
173#[cfg(test)]
174mod tests {
175 use bytes::Bytes;
176
177 use super::*;
178 use crate::TempFile;
179
180 #[test]
181 fn file_kind_classifies_common_mime_families() {
182 assert_eq!(FileKind::from_mime("video/mp4"), FileKind::Video);
183 assert_eq!(FileKind::from_mime("audio/mpeg"), FileKind::Audio);
184 assert_eq!(FileKind::from_mime("image/png"), FileKind::Image);
185 assert_eq!(FileKind::from_mime("text/plain"), FileKind::Text);
186 assert_eq!(FileKind::from_mime("application/pdf"), FileKind::Document);
187 assert_eq!(
188 FileKind::from_mime(
189 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
190 ),
191 FileKind::Document
192 );
193 assert_eq!(FileKind::from_mime("application/zip"), FileKind::Archive);
194 assert_eq!(FileKind::from_mime("application/json"), FileKind::Text);
195 assert_eq!(
196 FileKind::from_mime("application/octet-stream"),
197 FileKind::Binary
198 );
199 assert_eq!(FileKind::from_mime("custom"), FileKind::Unknown);
200 }
201
202 #[tokio::test]
203 async fn mime_detection_prefers_magic_then_extension_then_default() {
204 let png = FileSource::from_bytes(Bytes::from_static(&[
205 0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 0, 0, 0, 0,
206 ]));
207 assert_eq!(detect_mime(&png).await.unwrap(), "image/png");
208 assert_eq!(detect_kind(&png).await.unwrap(), FileKind::Image);
209
210 let by_extension = FileSource::from_path("report.pdf");
211 assert_eq!(detect_mime(&by_extension).await.unwrap(), "application/pdf");
212
213 let url = FileSource::from_url("https://example.invalid/download");
214 assert_eq!(detect_mime(&url).await.unwrap(), "application/octet-stream");
215 }
216
217 #[tokio::test]
218 async fn file_meta_extracts_path_url_temp_and_bytes_details() {
219 let dir = tempfile::tempdir().unwrap();
220 let file = dir.path().join("sample.txt");
221 tokio::fs::write(&file, b"hello").await.unwrap();
222
223 let path_meta = file_meta(&FileSource::from_path(&file)).await.unwrap();
224 assert_eq!(path_meta.name.as_deref(), Some("sample.txt"));
225 assert_eq!(path_meta.extension.as_deref(), Some("txt"));
226 assert_eq!(path_meta.mime_type, "text/plain");
227 assert_eq!(path_meta.size, Some(5));
228 assert!(path_meta.modified_at.is_some());
229
230 let url_meta = file_meta(&FileSource::from_url(
231 "https://example.invalid/assets/archive.tar.gz?download=1",
232 ))
233 .await
234 .unwrap();
235 assert_eq!(url_meta.name.as_deref(), Some("archive.tar.gz"));
236 assert_eq!(url_meta.extension.as_deref(), Some("gz"));
237 assert_eq!(url_meta.size, None);
238
239 let temp = TempFile::new().unwrap();
240 tokio::fs::write(temp.path(), b"temp").await.unwrap();
241 let temp_meta = file_meta(&FileSource::Temp(temp)).await.unwrap();
242 assert_eq!(temp_meta.size, Some(4));
243
244 let bytes_meta = file_meta(&FileSource::from_bytes(Bytes::from_static(b"bytes")))
245 .await
246 .unwrap();
247 assert_eq!(bytes_meta.name, None);
248 assert_eq!(bytes_meta.size, Some(5));
249 }
250}