Skip to main content

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