Skip to main content

rskit_storage/
meta.rs

1//! File metadata and MIME type detection.
2
3use chrono::{DateTime, Utc};
4use rskit_errors::AppResult;
5use rskit_fs::async_io::file;
6use serde::{Deserialize, Serialize};
7
8use crate::FileSource;
9
10/// Metadata about a file.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FileMeta {
13    /// Original file name, if known.
14    pub name: Option<String>,
15    /// File extension, if known.
16    pub extension: Option<String>,
17    /// MIME type (e.g., "video/mp4").
18    pub mime_type: String,
19    /// File size in bytes.
20    pub size: Option<u64>,
21    /// Creation timestamp.
22    pub created_at: Option<DateTime<Utc>>,
23    /// Last modification timestamp.
24    pub modified_at: Option<DateTime<Utc>>,
25    /// Checksum (e.g., SHA-256 hex string).
26    pub checksum: Option<String>,
27}
28
29/// Broad file category for routing to the right processor.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub enum FileKind {
32    /// Video file (e.g., mp4, mkv, webm).
33    Video,
34    /// Audio file (e.g., mp3, wav, flac).
35    Audio,
36    /// Image file (e.g., png, jpeg, webp).
37    Image,
38    /// Document (e.g., pdf, docx).
39    Document,
40    /// Archive (e.g., zip, tar.gz).
41    Archive,
42    /// Plain text file.
43    Text,
44    /// Generic binary file.
45    Binary,
46    /// Could not determine file type.
47    Unknown,
48}
49
50impl FileKind {
51    /// Determine the file kind from a MIME type string.
52    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
91/// Detect the MIME type of a file source.
92///
93/// Uses magic-byte detection on the first bytes of the file, falling back to extension-based guessing.
94pub async fn detect_mime(source: &FileSource) -> AppResult<String> {
95    // Try magic-byte detection first
96    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    // Fall back to extension-based guessing
109    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
117/// Detect the broad file kind of a source.
118pub async fn detect_kind(source: &FileSource) -> AppResult<FileKind> {
119    let mime = detect_mime(source).await?;
120    Ok(FileKind::from_mime(&mime))
121}
122
123/// Extract full metadata from a file source.
124pub 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}