aster_forge_file_classification/
lib.rs

1//! File extension parsing and category classification helpers.
2//!
3//! This crate turns filenames, MIME hints, and extension filter strings into stable high-level
4//! categories used by API filtering and UI grouping. It intentionally avoids product-specific enum
5//! derives so services can map categories into their own database or `OpenAPI` representations.
6#![cfg_attr(
7    not(test),
8    deny(
9        clippy::unwrap_used,
10        clippy::unreachable,
11        clippy::expect_used,
12        clippy::panic,
13        clippy::unimplemented,
14        clippy::todo
15    )
16)]
17
18use std::str::FromStr;
19
20#[cfg(feature = "sea-orm")]
21use sea_orm::entity::prelude::*;
22
23/// Storage width required for persisted extension and category values.
24///
25/// Products persisting [`FileClassification::extension`],
26/// [`FileClassification::compound_extension`], or [`FileCategory`] should use a string column with
27/// at least this width. Increasing this value is a schema compatibility change for consumers.
28pub const FILE_CLASSIFICATION_STORAGE_LEN: u32 = 32;
29/// Maximum accepted extension filter length.
30pub const MAX_EXTENSION_LEN: usize = FILE_CLASSIFICATION_STORAGE_LEN as usize;
31/// Maximum number of extension filters accepted in one filter string.
32pub const MAX_EXTENSION_FILTERS: usize = 32;
33
34/// Result type returned by file classification helpers.
35pub type Result<T> = std::result::Result<T, FileClassificationError>;
36
37/// Error returned when extension or category parsing fails.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39#[error("{message}")]
40pub struct FileClassificationError {
41    message: String,
42}
43
44impl FileClassificationError {
45    /// Creates a classification error with a message.
46    pub fn new(message: impl Into<String>) -> Self {
47        Self {
48            message: message.into(),
49        }
50    }
51
52    /// Returns the stored error message.
53    #[must_use]
54    pub fn message(&self) -> &str {
55        &self.message
56    }
57}
58
59/// High-level file category inferred from extension and MIME type.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
62#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
63#[cfg_attr(
64    feature = "sea-orm",
65    sea_orm(rs_type = "String", db_type = "String(StringLen::N(32))")
66)]
67#[serde(rename_all = "lowercase")]
68pub enum FileCategory {
69    /// Image files.
70    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "image"))]
71    Image,
72    /// Video files.
73    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "video"))]
74    Video,
75    /// Audio files.
76    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "audio"))]
77    Audio,
78    /// Document and plain text files.
79    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "document"))]
80    Document,
81    /// Spreadsheet files.
82    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "spreadsheet"))]
83    Spreadsheet,
84    /// Presentation files.
85    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "presentation"))]
86    Presentation,
87    /// Archive and compressed files.
88    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "archive"))]
89    Archive,
90    /// Source code and structured text files.
91    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "code"))]
92    Code,
93    /// Files that do not match a known category.
94    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "other"))]
95    Other,
96}
97
98impl FileCategory {
99    /// Returns the lowercase stable string representation.
100    #[must_use]
101    pub const fn as_str(self) -> &'static str {
102        match self {
103            Self::Image => "image",
104            Self::Video => "video",
105            Self::Audio => "audio",
106            Self::Document => "document",
107            Self::Spreadsheet => "spreadsheet",
108            Self::Presentation => "presentation",
109            Self::Archive => "archive",
110            Self::Code => "code",
111            Self::Other => "other",
112        }
113    }
114}
115
116impl FromStr for FileCategory {
117    type Err = ();
118
119    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
120        match value {
121            "image" => Ok(Self::Image),
122            "video" => Ok(Self::Video),
123            "audio" => Ok(Self::Audio),
124            "document" => Ok(Self::Document),
125            "spreadsheet" => Ok(Self::Spreadsheet),
126            "presentation" => Ok(Self::Presentation),
127            "archive" => Ok(Self::Archive),
128            "code" => Ok(Self::Code),
129            "other" => Ok(Self::Other),
130            _ => Err(()),
131        }
132    }
133}
134
135const COMPOUND_EXTENSIONS: &[&str] = &[
136    "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "tar.br", "tar.lz", "tar.lzma", "tar.lzo",
137];
138
139const IMAGE_EXTENSIONS: &[&str] = &[
140    "jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "svg", "ico", "avif", "heic",
141    "heif", "raw", "cr2", "nef", "orf", "rw2",
142];
143
144// "ts" is deliberately absent: it collides with TypeScript source files and
145// code wins (see CODE_EXTENSIONS). MPEG transport streams keep the specific
146// "m2ts" extension and the `video/*` MIME fallback.
147const VIDEO_EXTENSIONS: &[&str] = &[
148    "mp4", "m4v", "mov", "avi", "mkv", "webm", "flv", "wmv", "mpeg", "mpg", "3gp", "m2ts", "ogv",
149];
150
151const AUDIO_EXTENSIONS: &[&str] = &[
152    "mp3", "wav", "flac", "aac", "m4a", "ogg", "oga", "opus", "wma", "aiff", "alac", "mid", "midi",
153];
154
155const DOCUMENT_EXTENSIONS: &[&str] = &[
156    "pdf", "txt", "md", "markdown", "rtf", "doc", "docx", "odt", "pages", "epub", "tex",
157];
158
159const SPREADSHEET_EXTENSIONS: &[&str] = &["xls", "xlsx", "ods", "csv", "tsv", "numbers"];
160
161const PRESENTATION_EXTENSIONS: &[&str] = &["ppt", "pptx", "odp", "key"];
162
163const ARCHIVE_EXTENSIONS: &[&str] = &[
164    "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "br", "tgz", "tbz", "tbz2", "txz", "lz",
165    "lzma", "lzo", "cab", "iso", "dmg",
166];
167
168const CODE_EXTENSIONS: &[&str] = &[
169    "rs",
170    "ts",
171    "tsx",
172    "js",
173    "jsx",
174    "mjs",
175    "cjs",
176    "json",
177    "jsonc",
178    "yaml",
179    "yml",
180    "toml",
181    "xml",
182    "html",
183    "htm",
184    "css",
185    "scss",
186    "sass",
187    "less",
188    "sql",
189    "sh",
190    "bash",
191    "zsh",
192    "fish",
193    "ps1",
194    "py",
195    "rb",
196    "go",
197    "java",
198    "kt",
199    "kts",
200    "swift",
201    "c",
202    "h",
203    "cpp",
204    "cc",
205    "cxx",
206    "hpp",
207    "cs",
208    "php",
209    "lua",
210    "dart",
211    "vue",
212    "svelte",
213    "lock",
214    "ini",
215    "conf",
216    "dockerfile",
217    "makefile",
218];
219
220/// Parsed classification details for a file name and MIME type.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct FileClassification {
223    /// Lowercase final extension, without a leading dot.
224    pub extension: String,
225    /// Recognized compound extension, such as `tar.gz`.
226    pub compound_extension: Option<String>,
227    /// Inferred high-level file category.
228    pub category: FileCategory,
229}
230
231/// Classifies a file from its name and MIME type.
232#[must_use]
233pub fn classify_file(name: &str, mime_type: &str) -> FileClassification {
234    let extension = extension_from_name(name).unwrap_or_default();
235    let compound_extension = compound_extension_from_name(name);
236    let category =
237        classify_extension_and_mime(&extension, compound_extension.as_deref(), mime_type);
238
239    FileClassification {
240        extension,
241        compound_extension,
242        category,
243    }
244}
245
246/// Normalizes one extension filter value.
247///
248/// # Errors
249///
250/// Returns an error when the normalized extension is empty, exceeds [`MAX_EXTENSION_LEN`], has
251/// invalid dot placement, or contains unsupported characters.
252pub fn normalize_extension_filter(raw: &str) -> Result<String> {
253    let normalized = raw.trim().trim_start_matches('.').to_ascii_lowercase();
254    if normalized.is_empty() {
255        return Err(FileClassificationError::new(
256            "extensions must not contain empty values",
257        ));
258    }
259    if normalized.len() > MAX_EXTENSION_LEN {
260        return Err(FileClassificationError::new(format!(
261            "extensions must be at most {MAX_EXTENSION_LEN} characters"
262        )));
263    }
264    if normalized.starts_with('.')
265        || normalized.ends_with('.')
266        || normalized.contains("..")
267        || !normalized.chars().all(|ch| {
268            ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' || ch == '+'
269        })
270    {
271        return Err(FileClassificationError::new(
272            "extensions may only contain letters, numbers, dot, underscore, plus, or hyphen",
273        ));
274    }
275
276    Ok(normalized)
277}
278
279/// Parses a comma-separated list of extension filters.
280///
281/// # Errors
282///
283/// Returns an error when any entry is invalid or the distinct normalized list exceeds
284/// [`MAX_EXTENSION_FILTERS`].
285pub fn parse_extension_filters(raw: &str) -> Result<Vec<String>> {
286    let mut extensions = Vec::new();
287    for part in raw.split(',') {
288        let extension = normalize_extension_filter(part)?;
289        if !extensions.iter().any(|candidate| candidate == &extension) {
290            extensions.push(extension);
291        }
292        if extensions.len() > MAX_EXTENSION_FILTERS {
293            return Err(FileClassificationError::new(format!(
294                "extensions supports at most {MAX_EXTENSION_FILTERS} values"
295            )));
296        }
297    }
298
299    Ok(extensions)
300}
301
302/// Parses a file category from its lowercase string representation.
303///
304/// # Errors
305///
306/// Returns an error when `raw` is not one of the stable [`FileCategory`] string values.
307pub fn parse_file_category(raw: &str) -> Result<FileCategory> {
308    FileCategory::from_str(raw.trim()).map_err(|()| {
309        FileClassificationError::new(
310            "category must be one of: image, video, audio, document, spreadsheet, presentation, archive, code, other",
311        )
312    })
313}
314
315/// Extracts the lowercase final extension from a file name.
316///
317/// Only ASCII-alphanumeric candidates count as extensions; path-like input
318/// (`"dir.ext/file"`) or names whose suffix contains spaces/punctuation return
319/// `None`, because the extracted value can be persisted and shown in UIs.
320#[must_use]
321pub fn extension_from_name(name: &str) -> Option<String> {
322    let trimmed = name.trim();
323    let dot = trimmed.rfind('.')?;
324    if dot == 0 || dot + 1 >= trimmed.len() {
325        return None;
326    }
327    let extension = &trimmed[dot + 1..];
328    if extension.is_empty()
329        || extension.len() > MAX_EXTENSION_LEN
330        || !extension.chars().all(|ch| ch.is_ascii_alphanumeric())
331    {
332        return None;
333    }
334    Some(extension.to_ascii_lowercase())
335}
336
337/// Extracts a recognized compound extension from a file name.
338#[must_use]
339pub fn compound_extension_from_name(name: &str) -> Option<String> {
340    let normalized = name.trim().to_ascii_lowercase();
341    COMPOUND_EXTENSIONS
342        .iter()
343        .find(|extension| normalized.ends_with(&format!(".{extension}")))
344        .map(|extension| (*extension).to_string())
345}
346
347fn classify_extension_and_mime(
348    extension: &str,
349    compound_extension: Option<&str>,
350    mime_type: &str,
351) -> FileCategory {
352    if compound_extension.is_some() || contains(ARCHIVE_EXTENSIONS, extension) {
353        return FileCategory::Archive;
354    }
355    if contains(SPREADSHEET_EXTENSIONS, extension) {
356        return FileCategory::Spreadsheet;
357    }
358    if contains(PRESENTATION_EXTENSIONS, extension) {
359        return FileCategory::Presentation;
360    }
361    if contains(IMAGE_EXTENSIONS, extension) {
362        return FileCategory::Image;
363    }
364    if contains(VIDEO_EXTENSIONS, extension) {
365        return FileCategory::Video;
366    }
367    if contains(AUDIO_EXTENSIONS, extension) {
368        return FileCategory::Audio;
369    }
370    if contains(DOCUMENT_EXTENSIONS, extension) {
371        return FileCategory::Document;
372    }
373    if contains(CODE_EXTENSIONS, extension) {
374        return FileCategory::Code;
375    }
376
377    classify_mime(mime_type)
378}
379
380fn classify_mime(mime_type: &str) -> FileCategory {
381    let mime = mime_type.trim().to_ascii_lowercase();
382    if mime.starts_with("image/") {
383        FileCategory::Image
384    } else if mime.starts_with("video/") {
385        FileCategory::Video
386    } else if mime.starts_with("audio/") {
387        FileCategory::Audio
388    } else if mime.contains("spreadsheet") || mime.contains("excel") || mime.ends_with("/csv") {
389        // This must precede the generic `text/` branch: `text/csv` starts
390        // with `text/` and would otherwise never reach the `/csv` check.
391        FileCategory::Spreadsheet
392    } else if mime == "application/pdf" || mime.starts_with("text/") {
393        FileCategory::Document
394    } else if mime.contains("presentation") || mime.contains("powerpoint") {
395        FileCategory::Presentation
396    } else if mime.contains("zip")
397        || mime.contains("compressed")
398        || mime.contains("x-tar")
399        || mime.contains("x-7z")
400        || mime.contains("x-rar")
401    {
402        FileCategory::Archive
403    } else if mime.contains("json") || mime.contains("xml") {
404        FileCategory::Code
405    } else {
406        FileCategory::Other
407    }
408}
409
410const fn contains(values: &[&str], needle: &str) -> bool {
411    let mut index = 0;
412    while index < values.len() {
413        if values[index].len() == needle.len() {
414            let a = values[index].as_bytes();
415            let b = needle.as_bytes();
416            let mut byte_index = 0;
417            let mut equal = true;
418            while byte_index < a.len() {
419                if a[byte_index] != b[byte_index] {
420                    equal = false;
421                    break;
422                }
423                byte_index += 1;
424            }
425            if equal {
426                return true;
427            }
428        }
429        index += 1;
430    }
431    false
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn parses_extensions_and_compound_extensions() {
440        assert_eq!(extension_from_name("backup.tar.gz").as_deref(), Some("gz"));
441        assert_eq!(
442            compound_extension_from_name("backup.TAR.GZ").as_deref(),
443            Some("tar.gz")
444        );
445        assert_eq!(extension_from_name(".gitignore"), None);
446        assert_eq!(extension_from_name("README"), None);
447        assert_eq!(
448            extension_from_name(&format!("file.{}", "a".repeat(33))),
449            None
450        );
451    }
452
453    #[test]
454    fn classifies_with_fixed_priority() {
455        let csv = classify_file("data.csv", "text/csv");
456        assert_eq!(csv.category, FileCategory::Spreadsheet);
457
458        let markdown = classify_file("README.md", "text/markdown");
459        assert_eq!(markdown.category, FileCategory::Document);
460
461        let archive = classify_file("backup.tar.gz", "application/gzip");
462        assert_eq!(archive.category, FileCategory::Archive);
463        assert_eq!(archive.compound_extension.as_deref(), Some("tar.gz"));
464    }
465
466    #[test]
467    fn classifies_from_mime_when_extension_is_unknown() {
468        assert_eq!(
469            classify_file("asset.unknown", "image/png").category,
470            FileCategory::Image
471        );
472        assert_eq!(
473            classify_file("asset.unknown", "application/vnd.ms-excel").category,
474            FileCategory::Spreadsheet
475        );
476        assert_eq!(
477            classify_file("asset.unknown", "application/json").category,
478            FileCategory::Code
479        );
480        assert_eq!(
481            classify_file("asset.unknown", "application/octet-stream").category,
482            FileCategory::Other
483        );
484    }
485
486    #[test]
487    fn classifies_text_csv_mime_as_spreadsheet_when_extension_is_unknown() {
488        // The spreadsheet branch must run before the generic `text/` branch,
489        // or `text/csv` always classifies as Document and the `/csv` arm is
490        // unreachable.
491        assert_eq!(
492            classify_file("data.unknown", "text/csv").category,
493            FileCategory::Spreadsheet
494        );
495        assert_eq!(
496            classify_file("notes.unknown", "text/markdown").category,
497            FileCategory::Document
498        );
499    }
500
501    #[test]
502    fn extension_from_name_rejects_path_like_and_spaced_candidates() {
503        // The extracted value can be persisted, so candidates that are clearly
504        // not extensions must be rejected instead of leaking garbage into DB
505        // rows, logs, and UI labels.
506        assert_eq!(extension_from_name("dir.ext/file"), None);
507        assert_eq!(extension_from_name("report.pn g"), None);
508        assert_eq!(extension_from_name("archive.tar.gz").as_deref(), Some("gz"));
509        assert_eq!(extension_from_name("photo.JPEG").as_deref(), Some("jpeg"));
510    }
511
512    #[test]
513    fn classifies_ts_extension_as_code_not_video() {
514        // "ts" is TypeScript source in practice; MPEG transport streams keep
515        // the specific "m2ts" extension and the video/mp2t MIME fallback.
516        assert_eq!(
517            classify_file("index.ts", "video/mp2t").category,
518            FileCategory::Code
519        );
520        assert_eq!(
521            classify_file("stream.m2ts", "video/mp2t").category,
522            FileCategory::Video
523        );
524        assert_eq!(
525            classify_file("asset.unknown", "video/mp2t").category,
526            FileCategory::Video
527        );
528    }
529
530    #[test]
531    fn parses_file_category_values() {
532        assert_eq!(parse_file_category(" image ").unwrap(), FileCategory::Image);
533        assert_eq!(FileCategory::Archive.as_str(), "archive");
534        assert!(parse_file_category("folder").is_err());
535    }
536
537    #[test]
538    fn normalizes_extension_filters() {
539        assert_eq!(
540            parse_extension_filters(" .PDF,docx,pdf ").unwrap(),
541            vec!["pdf", "docx"]
542        );
543        assert!(parse_extension_filters("pdf,,docx").is_err());
544        assert!(parse_extension_filters("../pdf").is_err());
545    }
546
547    #[test]
548    fn extension_filters_reject_length_and_count_boundaries() {
549        assert!(normalize_extension_filter(&"a".repeat(MAX_EXTENSION_LEN + 1)).is_err());
550
551        let too_many = (0..=MAX_EXTENSION_FILTERS)
552            .map(|index| format!("ext{index}"))
553            .collect::<Vec<_>>()
554            .join(",");
555        assert!(parse_extension_filters(&too_many).is_err());
556    }
557
558    #[cfg(feature = "sea-orm")]
559    #[test]
560    fn file_category_has_stable_sea_orm_values() {
561        assert_eq!(FileCategory::Image.to_value(), "image");
562        assert_eq!(FileCategory::Archive.to_value(), "archive");
563        assert_eq!(
564            FileCategory::try_from_value(&"spreadsheet".to_string()),
565            Ok(FileCategory::Spreadsheet)
566        );
567        assert!(FileCategory::try_from_value(&"folder".to_string()).is_err());
568        for category in [
569            FileCategory::Image,
570            FileCategory::Video,
571            FileCategory::Audio,
572            FileCategory::Document,
573            FileCategory::Spreadsheet,
574            FileCategory::Presentation,
575            FileCategory::Archive,
576            FileCategory::Code,
577            FileCategory::Other,
578        ] {
579            assert!(category.to_value().len() <= FILE_CLASSIFICATION_STORAGE_LEN as usize);
580        }
581    }
582}