⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/desktop/
mime.rs

1//! MIME Type Database
2//!
3//! Provides MIME type detection via file extension and magic byte analysis,
4//! with associated application dispatch. Used by the file manager to open
5//! files with the appropriate application.
6//!
7//! Detection strategy:
8//! 1. Magic byte signatures (most reliable, checks file header bytes)
9//! 2. File extension mapping (50+ extensions supported)
10//! 3. Fallback to `Unknown`
11//!
12//! The file manager calls `MimeDatabase::detect_mime()` to determine a file's
13//! MIME type, then `MimeDatabase::open_with()` to look up the associated
14//! application, and finally launches that application with the file path as
15//! an argument.
16
17#![allow(dead_code)]
18
19use alloc::{string::String, vec, vec::Vec};
20
21// ---------------------------------------------------------------------------
22// MIME type enumeration
23// ---------------------------------------------------------------------------
24
25/// Supported MIME types
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MimeType {
28    // Text types
29    TextPlain,
30    TextHtml,
31    TextCss,
32    TextJavascript,
33    TextXml,
34    TextMarkdown,
35    TextRust,
36    TextC,
37    TextCpp,
38    TextPython,
39    TextShell,
40
41    // Image types
42    ImagePng,
43    ImageJpeg,
44    ImageGif,
45    ImageBmp,
46    ImageSvg,
47    ImagePpm,
48
49    // Audio types
50    AudioWav,
51    AudioMp3,
52    AudioOgg,
53
54    // Video types
55    VideoMp4,
56    VideoAvi,
57
58    // Application types
59    ApplicationPdf,
60    ApplicationZip,
61    ApplicationTar,
62    ApplicationGzip,
63    ApplicationElf,
64    ApplicationDesktop,
65
66    // Special types
67    DirectoryType,
68    Unknown,
69}
70
71// ---------------------------------------------------------------------------
72// MIME category
73// ---------------------------------------------------------------------------
74
75/// Broad category for a MIME type
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MimeCategory {
78    Text,
79    Image,
80    Audio,
81    Video,
82    Application,
83    Directory,
84}
85
86// ---------------------------------------------------------------------------
87// Application association
88// ---------------------------------------------------------------------------
89
90/// Maps a MIME type to the application that should open it
91#[derive(Debug, Clone)]
92pub struct MimeAssociation {
93    /// The MIME type this association applies to
94    pub mime_type: MimeType,
95    /// Human-readable application name (e.g. "Text Editor")
96    pub app_name: String,
97    /// Executable path (e.g. "/bin/text-editor")
98    pub app_exec: String,
99}
100
101// ---------------------------------------------------------------------------
102// MIME database
103// ---------------------------------------------------------------------------
104
105/// Central database for MIME type detection and application dispatch.
106///
107/// Contains a set of built-in default associations (populated in `new()`) plus
108/// user-registered custom associations that take priority.
109pub struct MimeDatabase {
110    /// Built-in default associations (populated by `new()`)
111    associations: Vec<MimeAssociation>,
112    /// User-registered associations (checked first, override defaults)
113    custom_associations: Vec<MimeAssociation>,
114}
115
116impl MimeDatabase {
117    /// Create a new MIME database populated with default associations.
118    ///
119    /// Default dispatch table:
120    /// - Text types -> Text Editor (`/bin/text-editor`)
121    /// - Image types -> Image Viewer (`/bin/image-viewer`)
122    /// - ELF binaries -> Terminal (`/bin/terminal`)
123    /// - Directories -> File Manager (`/bin/file-manager`)
124    /// - Everything else -> Text Editor (fallback)
125    pub fn new() -> Self {
126        let text_editor_name = String::from("Text Editor");
127        let text_editor_exec = String::from("/bin/text-editor");
128        let image_viewer_name = String::from("Image Viewer");
129        let image_viewer_exec = String::from("/bin/image-viewer");
130
131        let associations = vec![
132            // ---- Text types -> Text Editor ----
133            MimeAssociation {
134                mime_type: MimeType::TextPlain,
135                app_name: text_editor_name.clone(),
136                app_exec: text_editor_exec.clone(),
137            },
138            MimeAssociation {
139                mime_type: MimeType::TextHtml,
140                app_name: text_editor_name.clone(),
141                app_exec: text_editor_exec.clone(),
142            },
143            MimeAssociation {
144                mime_type: MimeType::TextCss,
145                app_name: text_editor_name.clone(),
146                app_exec: text_editor_exec.clone(),
147            },
148            MimeAssociation {
149                mime_type: MimeType::TextJavascript,
150                app_name: text_editor_name.clone(),
151                app_exec: text_editor_exec.clone(),
152            },
153            MimeAssociation {
154                mime_type: MimeType::TextXml,
155                app_name: text_editor_name.clone(),
156                app_exec: text_editor_exec.clone(),
157            },
158            MimeAssociation {
159                mime_type: MimeType::TextMarkdown,
160                app_name: text_editor_name.clone(),
161                app_exec: text_editor_exec.clone(),
162            },
163            MimeAssociation {
164                mime_type: MimeType::TextRust,
165                app_name: text_editor_name.clone(),
166                app_exec: text_editor_exec.clone(),
167            },
168            MimeAssociation {
169                mime_type: MimeType::TextC,
170                app_name: text_editor_name.clone(),
171                app_exec: text_editor_exec.clone(),
172            },
173            MimeAssociation {
174                mime_type: MimeType::TextCpp,
175                app_name: text_editor_name.clone(),
176                app_exec: text_editor_exec.clone(),
177            },
178            MimeAssociation {
179                mime_type: MimeType::TextPython,
180                app_name: text_editor_name.clone(),
181                app_exec: text_editor_exec.clone(),
182            },
183            MimeAssociation {
184                mime_type: MimeType::TextShell,
185                app_name: text_editor_name.clone(),
186                app_exec: text_editor_exec.clone(),
187            },
188            // ---- Image types -> Image Viewer ----
189            MimeAssociation {
190                mime_type: MimeType::ImagePng,
191                app_name: image_viewer_name.clone(),
192                app_exec: image_viewer_exec.clone(),
193            },
194            MimeAssociation {
195                mime_type: MimeType::ImageJpeg,
196                app_name: image_viewer_name.clone(),
197                app_exec: image_viewer_exec.clone(),
198            },
199            MimeAssociation {
200                mime_type: MimeType::ImageGif,
201                app_name: image_viewer_name.clone(),
202                app_exec: image_viewer_exec.clone(),
203            },
204            MimeAssociation {
205                mime_type: MimeType::ImageBmp,
206                app_name: image_viewer_name.clone(),
207                app_exec: image_viewer_exec.clone(),
208            },
209            MimeAssociation {
210                mime_type: MimeType::ImageSvg,
211                app_name: image_viewer_name.clone(),
212                app_exec: image_viewer_exec.clone(),
213            },
214            MimeAssociation {
215                mime_type: MimeType::ImagePpm,
216                app_name: image_viewer_name.clone(),
217                app_exec: image_viewer_exec.clone(),
218            },
219            // ---- Audio types -> Text Editor (placeholder, no audio player yet) ----
220            MimeAssociation {
221                mime_type: MimeType::AudioWav,
222                app_name: text_editor_name.clone(),
223                app_exec: text_editor_exec.clone(),
224            },
225            MimeAssociation {
226                mime_type: MimeType::AudioMp3,
227                app_name: text_editor_name.clone(),
228                app_exec: text_editor_exec.clone(),
229            },
230            MimeAssociation {
231                mime_type: MimeType::AudioOgg,
232                app_name: text_editor_name.clone(),
233                app_exec: text_editor_exec.clone(),
234            },
235            // ---- Video types -> Text Editor (placeholder, no video player yet) ----
236            MimeAssociation {
237                mime_type: MimeType::VideoMp4,
238                app_name: text_editor_name.clone(),
239                app_exec: text_editor_exec.clone(),
240            },
241            MimeAssociation {
242                mime_type: MimeType::VideoAvi,
243                app_name: text_editor_name.clone(),
244                app_exec: text_editor_exec.clone(),
245            },
246            // ---- Application types ----
247            MimeAssociation {
248                mime_type: MimeType::ApplicationPdf,
249                app_name: text_editor_name.clone(),
250                app_exec: text_editor_exec.clone(),
251            },
252            MimeAssociation {
253                mime_type: MimeType::ApplicationZip,
254                app_name: text_editor_name.clone(),
255                app_exec: text_editor_exec.clone(),
256            },
257            MimeAssociation {
258                mime_type: MimeType::ApplicationTar,
259                app_name: text_editor_name.clone(),
260                app_exec: text_editor_exec.clone(),
261            },
262            MimeAssociation {
263                mime_type: MimeType::ApplicationGzip,
264                app_name: text_editor_name.clone(),
265                app_exec: text_editor_exec.clone(),
266            },
267            MimeAssociation {
268                mime_type: MimeType::ApplicationElf,
269                app_name: String::from("Terminal"),
270                app_exec: String::from("/bin/terminal"),
271            },
272            MimeAssociation {
273                mime_type: MimeType::ApplicationDesktop,
274                app_name: text_editor_name.clone(),
275                app_exec: text_editor_exec.clone(),
276            },
277            // ---- Special types ----
278            MimeAssociation {
279                mime_type: MimeType::DirectoryType,
280                app_name: String::from("File Manager"),
281                app_exec: String::from("/bin/file-manager"),
282            },
283            // ---- Fallback for Unknown ----
284            MimeAssociation {
285                mime_type: MimeType::Unknown,
286                app_name: text_editor_name,
287                app_exec: text_editor_exec,
288            },
289        ];
290
291        Self {
292            associations,
293            custom_associations: Vec::new(),
294        }
295    }
296
297    // -----------------------------------------------------------------------
298    // Detection
299    // -----------------------------------------------------------------------
300
301    /// Detect the MIME type of a file.
302    ///
303    /// Detection order:
304    /// 1. Magic byte signatures (if `header_bytes` is `Some`)
305    /// 2. File extension
306    /// 3. `MimeType::Unknown` fallback
307    pub fn detect_mime(filename: &str, header_bytes: Option<&[u8]>) -> MimeType {
308        // Step 1: Try magic bytes first (most reliable)
309        if let Some(bytes) = header_bytes {
310            if let Some(mime) = Self::detect_from_magic(bytes) {
311                return mime;
312            }
313        }
314
315        // Step 2: Try extension
316        if let Some(ext) = get_extension(filename) {
317            return detect_mime_from_extension(ext);
318        }
319
320        // Step 3: Fallback
321        MimeType::Unknown
322    }
323
324    /// Attempt to detect MIME type from magic byte signatures.
325    ///
326    /// Checks the first few bytes of a file against well-known magic numbers.
327    /// Returns `None` if no match is found.
328    fn detect_from_magic(bytes: &[u8]) -> Option<MimeType> {
329        if bytes.len() < 2 {
330            return None;
331        }
332
333        // PNG: 89 50 4E 47 0D 0A 1A 0A
334        if bytes.len() >= 4
335            && bytes[0] == 0x89
336            && bytes[1] == 0x50
337            && bytes[2] == 0x4E
338            && bytes[3] == 0x47
339        {
340            return Some(MimeType::ImagePng);
341        }
342
343        // JPEG: FF D8 FF
344        if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
345            return Some(MimeType::ImageJpeg);
346        }
347
348        // GIF: 47 49 46 ("GIF")
349        if bytes.len() >= 3 && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 {
350            return Some(MimeType::ImageGif);
351        }
352
353        // BMP: 42 4D ("BM")
354        if bytes[0] == 0x42 && bytes[1] == 0x4D {
355            return Some(MimeType::ImageBmp);
356        }
357
358        // PDF: 25 50 44 46 ("%PDF")
359        if bytes.len() >= 4
360            && bytes[0] == 0x25
361            && bytes[1] == 0x50
362            && bytes[2] == 0x44
363            && bytes[3] == 0x46
364        {
365            return Some(MimeType::ApplicationPdf);
366        }
367
368        // ZIP / PK archives: 50 4B 03 04
369        if bytes.len() >= 4
370            && bytes[0] == 0x50
371            && bytes[1] == 0x4B
372            && bytes[2] == 0x03
373            && bytes[3] == 0x04
374        {
375            return Some(MimeType::ApplicationZip);
376        }
377
378        // GZIP: 1F 8B
379        if bytes[0] == 0x1F && bytes[1] == 0x8B {
380            return Some(MimeType::ApplicationGzip);
381        }
382
383        // ELF: 7F 45 4C 46
384        if bytes.len() >= 4
385            && bytes[0] == 0x7F
386            && bytes[1] == 0x45
387            && bytes[2] == 0x4C
388            && bytes[3] == 0x46
389        {
390            return Some(MimeType::ApplicationElf);
391        }
392
393        // WAV: RIFF....WAVE
394        if bytes.len() >= 12
395            && bytes[0] == b'R'
396            && bytes[1] == b'I'
397            && bytes[2] == b'F'
398            && bytes[3] == b'F'
399            && bytes[8] == b'W'
400            && bytes[9] == b'A'
401            && bytes[10] == b'V'
402            && bytes[11] == b'E'
403        {
404            return Some(MimeType::AudioWav);
405        }
406
407        // OGG: 4F 67 67 53 ("OggS")
408        if bytes.len() >= 4
409            && bytes[0] == b'O'
410            && bytes[1] == b'g'
411            && bytes[2] == b'g'
412            && bytes[3] == b'S'
413        {
414            return Some(MimeType::AudioOgg);
415        }
416
417        // MP3: FF FB, FF F3, FF F2, or ID3 tag
418        if bytes.len() >= 3
419            && ((bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0)
420                || (bytes[0] == b'I' && bytes[1] == b'D' && bytes[2] == b'3'))
421        {
422            return Some(MimeType::AudioMp3);
423        }
424
425        // PPM: "P6" (binary) or "P3" (ASCII)
426        if bytes.len() >= 2 && bytes[0] == b'P' && (bytes[1] == b'6' || bytes[1] == b'3') {
427            return Some(MimeType::ImagePpm);
428        }
429
430        // SVG: check for "<?xml" or "<svg" at the start (heuristic)
431        if bytes.len() >= 5
432            && ((bytes[0] == b'<'
433                && bytes[1] == b'?'
434                && bytes[2] == b'x'
435                && bytes[3] == b'm'
436                && bytes[4] == b'l')
437                || (bytes.len() >= 4
438                    && bytes[0] == b'<'
439                    && bytes[1] == b's'
440                    && bytes[2] == b'v'
441                    && bytes[3] == b'g'))
442        {
443            // Could be SVG if it contains <svg, but the xml check alone is
444            // insufficient. We rely on extension for SVG primarily; this is
445            // a best-effort heuristic.
446            // For pure XML, return TextXml; extension-based detection will
447            // refine to SVG if the extension says so.
448            return Some(MimeType::TextXml);
449        }
450
451        // TAR: USTAR magic at offset 257
452        if bytes.len() >= 263
453            && bytes[257] == b'u'
454            && bytes[258] == b's'
455            && bytes[259] == b't'
456            && bytes[260] == b'a'
457            && bytes[261] == b'r'
458        {
459            return Some(MimeType::ApplicationTar);
460        }
461
462        None
463    }
464
465    // -----------------------------------------------------------------------
466    // Application dispatch
467    // -----------------------------------------------------------------------
468
469    /// Get the default application for a MIME type.
470    ///
471    /// Checks custom associations first, then falls back to the built-in
472    /// default table. Returns `None` only if both tables are empty (should
473    /// not happen with default construction).
474    pub fn open_with(&self, mime: &MimeType) -> Option<&MimeAssociation> {
475        // Custom associations take priority
476        for assoc in &self.custom_associations {
477            if assoc.mime_type == *mime {
478                return Some(assoc);
479            }
480        }
481
482        // Fall back to defaults
483        for assoc in &self.associations {
484            if assoc.mime_type == *mime {
485                return Some(assoc);
486            }
487        }
488
489        // Final fallback: try Unknown entry
490        if *mime != MimeType::Unknown {
491            for assoc in &self.associations {
492                if assoc.mime_type == MimeType::Unknown {
493                    return Some(assoc);
494                }
495            }
496        }
497
498        None
499    }
500
501    /// Register a custom MIME association.
502    ///
503    /// Custom associations take priority over built-in defaults. If an
504    /// association for the same MIME type already exists in the custom list,
505    /// it is replaced.
506    pub fn register_association(
507        &mut self,
508        mime_type: MimeType,
509        app_name: String,
510        app_exec: String,
511    ) {
512        // Remove any existing custom association for this MIME type
513        self.custom_associations
514            .retain(|a| a.mime_type != mime_type);
515
516        self.custom_associations.push(MimeAssociation {
517            mime_type,
518            app_name,
519            app_exec,
520        });
521    }
522
523    // -----------------------------------------------------------------------
524    // Classification helpers
525    // -----------------------------------------------------------------------
526
527    /// Return the broad category for a MIME type.
528    pub fn category(mime: &MimeType) -> MimeCategory {
529        match mime {
530            MimeType::TextPlain
531            | MimeType::TextHtml
532            | MimeType::TextCss
533            | MimeType::TextJavascript
534            | MimeType::TextXml
535            | MimeType::TextMarkdown
536            | MimeType::TextRust
537            | MimeType::TextC
538            | MimeType::TextCpp
539            | MimeType::TextPython
540            | MimeType::TextShell => MimeCategory::Text,
541
542            MimeType::ImagePng
543            | MimeType::ImageJpeg
544            | MimeType::ImageGif
545            | MimeType::ImageBmp
546            | MimeType::ImageSvg
547            | MimeType::ImagePpm => MimeCategory::Image,
548
549            MimeType::AudioWav | MimeType::AudioMp3 | MimeType::AudioOgg => MimeCategory::Audio,
550
551            MimeType::VideoMp4 | MimeType::VideoAvi => MimeCategory::Video,
552
553            MimeType::ApplicationPdf
554            | MimeType::ApplicationZip
555            | MimeType::ApplicationTar
556            | MimeType::ApplicationGzip
557            | MimeType::ApplicationElf
558            | MimeType::ApplicationDesktop => MimeCategory::Application,
559
560            MimeType::DirectoryType => MimeCategory::Directory,
561
562            MimeType::Unknown => MimeCategory::Application,
563        }
564    }
565
566    /// Return the standard MIME type string (e.g. `"text/plain"`).
567    pub fn mime_to_str(mime: &MimeType) -> &'static str {
568        match mime {
569            MimeType::TextPlain => "text/plain",
570            MimeType::TextHtml => "text/html",
571            MimeType::TextCss => "text/css",
572            MimeType::TextJavascript => "text/javascript",
573            MimeType::TextXml => "text/xml",
574            MimeType::TextMarkdown => "text/markdown",
575            MimeType::TextRust => "text/x-rust",
576            MimeType::TextC => "text/x-csrc",
577            MimeType::TextCpp => "text/x-c++src",
578            MimeType::TextPython => "text/x-python",
579            MimeType::TextShell => "text/x-shellscript",
580
581            MimeType::ImagePng => "image/png",
582            MimeType::ImageJpeg => "image/jpeg",
583            MimeType::ImageGif => "image/gif",
584            MimeType::ImageBmp => "image/bmp",
585            MimeType::ImageSvg => "image/svg+xml",
586            MimeType::ImagePpm => "image/x-portable-pixmap",
587
588            MimeType::AudioWav => "audio/wav",
589            MimeType::AudioMp3 => "audio/mpeg",
590            MimeType::AudioOgg => "audio/ogg",
591
592            MimeType::VideoMp4 => "video/mp4",
593            MimeType::VideoAvi => "video/x-msvideo",
594
595            MimeType::ApplicationPdf => "application/pdf",
596            MimeType::ApplicationZip => "application/zip",
597            MimeType::ApplicationTar => "application/x-tar",
598            MimeType::ApplicationGzip => "application/gzip",
599            MimeType::ApplicationElf => "application/x-elf",
600            MimeType::ApplicationDesktop => "application/x-desktop",
601
602            MimeType::DirectoryType => "inode/directory",
603
604            MimeType::Unknown => "application/octet-stream",
605        }
606    }
607
608    /// Return a BGRA color for the file type icon in the file manager.
609    ///
610    /// Colors are chosen for quick visual identification:
611    /// - Text/code files: muted shades
612    /// - Images: bright green
613    /// - Audio: orange
614    /// - Video: magenta
615    /// - Archives: yellow
616    /// - Executables: red
617    /// - Directories: blue (matches file_manager.rs existing `0x55AAFF`)
618    ///
619    /// The returned value is packed BGRA (B in bits 0-7, G in 8-15, R in
620    /// 16-23, A in 24-31) matching the framebuffer byte order.
621    pub fn icon_color(mime: &MimeType) -> u32 {
622        match mime {
623            // Text / code -- light gray
624            MimeType::TextPlain => 0xFFCCCCCC,
625
626            // Source code -- specific accent colors
627            MimeType::TextRust => 0xFFDE8A56, // Rust orange-brown (BGRA)
628            MimeType::TextC => 0xFFD19A55,    // C blue (looks brownish in BGRA)
629            MimeType::TextCpp => 0xFFCB6D9F,  // C++ rose
630            MimeType::TextPython => 0xFF55B4D1, // Python teal
631            MimeType::TextShell => 0xFF66CC66, // Shell green
632
633            MimeType::TextHtml => 0xFFE06633,       // HTML orange
634            MimeType::TextCss => 0xFFCC6699,        // CSS pink
635            MimeType::TextJavascript => 0xFF33CCDD, // JS cyan
636            MimeType::TextXml => 0xFFAA8866,        // XML tan
637            MimeType::TextMarkdown => 0xFFBBBBDD,   // Markdown lavender
638
639            // Images -- bright green
640            MimeType::ImagePng
641            | MimeType::ImageJpeg
642            | MimeType::ImageGif
643            | MimeType::ImageBmp
644            | MimeType::ImageSvg
645            | MimeType::ImagePpm => 0xFF44DD44,
646
647            // Audio -- orange
648            MimeType::AudioWav | MimeType::AudioMp3 | MimeType::AudioOgg => 0xFF44AAEE,
649
650            // Video -- magenta / purple
651            MimeType::VideoMp4 | MimeType::VideoAvi => 0xFFDD44DD,
652
653            // PDF -- dark red
654            MimeType::ApplicationPdf => 0xFF3333CC,
655
656            // Archives -- yellow
657            MimeType::ApplicationZip | MimeType::ApplicationTar | MimeType::ApplicationGzip => {
658                0xFF33DDDD
659            }
660
661            // ELF executable -- red
662            MimeType::ApplicationElf => 0xFF4444EE,
663
664            // Desktop entry -- cyan
665            MimeType::ApplicationDesktop => 0xFFDDBB33,
666
667            // Directory -- blue (matches file_manager.rs existing 0x55AAFF)
668            MimeType::DirectoryType => 0xFFFFAA55,
669
670            // Unknown -- dim gray
671            MimeType::Unknown => 0xFF888888,
672        }
673    }
674}
675
676impl Default for MimeDatabase {
677    fn default() -> Self {
678        Self::new()
679    }
680}
681
682// ---------------------------------------------------------------------------
683// Free-standing helpers
684// ---------------------------------------------------------------------------
685
686/// Extract the file extension from a filename, lowercased for comparison.
687///
688/// Returns the extension without the leading dot, or `None` if there is no
689/// extension. The returned slice borrows from `filename`.
690///
691/// # Examples (conceptual, no_std)
692/// - `"readme.txt"` -> `Some("txt")`
693/// - `"Makefile"` -> `None`
694/// - `"archive.tar.gz"` -> `Some("gz")`
695/// - `".hidden"` -> `None` (dot-files with no further extension)
696pub fn get_extension(filename: &str) -> Option<&str> {
697    // Find the last dot that is not the first character
698    let bytes = filename.as_bytes();
699    let mut dot_pos: Option<usize> = None;
700    let mut i = bytes.len();
701    while i > 0 {
702        i -= 1;
703        if bytes[i] == b'.' {
704            // Dot at position 0 is a hidden file prefix, not an extension
705            if i == 0 {
706                return None;
707            }
708            // Dot right after '/' is also not an extension (e.g. "path/.hidden")
709            if i > 0 && bytes[i - 1] == b'/' {
710                return None;
711            }
712            dot_pos = Some(i);
713            break;
714        }
715        // Stop searching if we hit a path separator
716        if bytes[i] == b'/' {
717            return None;
718        }
719    }
720
721    dot_pos.map(|pos| &filename[pos + 1..])
722}
723
724/// Detect MIME type purely from file extension.
725///
726/// Performs case-insensitive comparison by checking both the original
727/// extension and an ASCII-lowercased copy.
728pub fn detect_mime_from_extension(ext: &str) -> MimeType {
729    // We need case-insensitive matching. Since extensions are short ASCII
730    // strings, we lowercase into a small stack buffer. Extensions longer
731    // than 15 bytes are unsupported and fall through to Unknown.
732    let mut lower_buf = [0u8; 16];
733    let ext_bytes = ext.as_bytes();
734    if ext_bytes.len() > 15 {
735        return MimeType::Unknown;
736    }
737    for (i, &b) in ext_bytes.iter().enumerate() {
738        lower_buf[i] = if b.is_ascii_uppercase() { b + 32 } else { b };
739    }
740    let lower = core::str::from_utf8(&lower_buf[..ext_bytes.len()]).unwrap_or("");
741
742    match lower {
743        // Plain text
744        "txt" | "text" | "log" | "cfg" | "conf" | "ini" => MimeType::TextPlain,
745
746        // Markup / web
747        "html" | "htm" | "xhtml" => MimeType::TextHtml,
748        "css" => MimeType::TextCss,
749        "js" | "mjs" | "cjs" => MimeType::TextJavascript,
750        "xml" | "xsl" | "xslt" => MimeType::TextXml,
751        "md" | "markdown" | "mkd" => MimeType::TextMarkdown,
752
753        // Programming languages
754        "rs" => MimeType::TextRust,
755        "c" | "h" => MimeType::TextC,
756        "cpp" | "cxx" | "cc" | "hpp" | "hxx" | "hh" => MimeType::TextCpp,
757        "py" | "pyw" | "pyi" => MimeType::TextPython,
758        "sh" | "bash" | "zsh" | "fish" | "ksh" | "csh" => MimeType::TextShell,
759
760        // Additional text formats (treated as plain text)
761        "json" | "yaml" | "yml" | "toml" | "csv" | "tsv" => MimeType::TextPlain,
762        "diff" | "patch" => MimeType::TextPlain,
763        "makefile" => MimeType::TextPlain,
764
765        // Images
766        "png" => MimeType::ImagePng,
767        "jpg" | "jpeg" | "jpe" => MimeType::ImageJpeg,
768        "gif" => MimeType::ImageGif,
769        "bmp" | "dib" => MimeType::ImageBmp,
770        "svg" | "svgz" => MimeType::ImageSvg,
771        "ppm" | "pgm" | "pbm" | "pnm" => MimeType::ImagePpm,
772
773        // Audio
774        "wav" | "wave" => MimeType::AudioWav,
775        "mp3" => MimeType::AudioMp3,
776        "ogg" | "oga" | "opus" => MimeType::AudioOgg,
777
778        // Video
779        "mp4" | "m4v" => MimeType::VideoMp4,
780        "avi" => MimeType::VideoAvi,
781
782        // Application / archives
783        "pdf" => MimeType::ApplicationPdf,
784        "zip" | "jar" => MimeType::ApplicationZip,
785        "tar" => MimeType::ApplicationTar,
786        "gz" | "gzip" | "tgz" => MimeType::ApplicationGzip,
787        "elf" | "bin" | "out" => MimeType::ApplicationElf,
788        "desktop" => MimeType::ApplicationDesktop,
789
790        _ => MimeType::Unknown,
791    }
792}
793
794// ---------------------------------------------------------------------------
795// Display / Debug helpers for MimeType
796// ---------------------------------------------------------------------------
797
798impl MimeType {
799    /// Return the standard MIME string. Convenience wrapper around
800    /// `MimeDatabase::mime_to_str`.
801    pub fn as_str(&self) -> &'static str {
802        MimeDatabase::mime_to_str(self)
803    }
804
805    /// Return the broad category.
806    pub fn category(&self) -> MimeCategory {
807        MimeDatabase::category(self)
808    }
809
810    /// Return a BGRA icon color for this type.
811    pub fn icon_color(&self) -> u32 {
812        MimeDatabase::icon_color(self)
813    }
814
815    /// Return `true` if this is any text/source code type.
816    pub fn is_text(&self) -> bool {
817        matches!(self.category(), MimeCategory::Text)
818    }
819
820    /// Return `true` if this is any image type.
821    pub fn is_image(&self) -> bool {
822        matches!(self.category(), MimeCategory::Image)
823    }
824
825    /// Return `true` if this is any audio type.
826    pub fn is_audio(&self) -> bool {
827        matches!(self.category(), MimeCategory::Audio)
828    }
829
830    /// Return `true` if this is any video type.
831    pub fn is_video(&self) -> bool {
832        matches!(self.category(), MimeCategory::Video)
833    }
834
835    /// Return `true` if this is the directory pseudo-type.
836    pub fn is_directory(&self) -> bool {
837        *self == MimeType::DirectoryType
838    }
839
840    /// Return `true` if this is an executable binary.
841    pub fn is_executable(&self) -> bool {
842        *self == MimeType::ApplicationElf
843    }
844
845    /// Return `true` if this is an archive format.
846    pub fn is_archive(&self) -> bool {
847        matches!(
848            self,
849            MimeType::ApplicationZip | MimeType::ApplicationTar | MimeType::ApplicationGzip
850        )
851    }
852}
853
854// ---------------------------------------------------------------------------
855// Tests
856// ---------------------------------------------------------------------------
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    // -- Extension extraction -----------------------------------------------
863
864    #[test]
865    fn test_get_extension_basic() {
866        assert_eq!(get_extension("readme.txt"), Some("txt"));
867        assert_eq!(get_extension("archive.tar.gz"), Some("gz"));
868        assert_eq!(get_extension("Makefile"), None);
869        assert_eq!(get_extension(".hidden"), None);
870        assert_eq!(get_extension("path/.hidden"), None);
871        assert_eq!(get_extension("no_ext"), None);
872        assert_eq!(get_extension("photo.JPEG"), Some("JPEG"));
873    }
874
875    #[test]
876    fn test_get_extension_empty() {
877        assert_eq!(get_extension(""), None);
878        assert_eq!(get_extension("."), None);
879    }
880
881    // -- Extension-based detection ------------------------------------------
882
883    #[test]
884    fn test_detect_extension_text() {
885        assert_eq!(detect_mime_from_extension("txt"), MimeType::TextPlain);
886        assert_eq!(detect_mime_from_extension("TXT"), MimeType::TextPlain);
887        assert_eq!(detect_mime_from_extension("rs"), MimeType::TextRust);
888        assert_eq!(detect_mime_from_extension("c"), MimeType::TextC);
889        assert_eq!(detect_mime_from_extension("cpp"), MimeType::TextCpp);
890        assert_eq!(detect_mime_from_extension("py"), MimeType::TextPython);
891        assert_eq!(detect_mime_from_extension("sh"), MimeType::TextShell);
892        assert_eq!(detect_mime_from_extension("md"), MimeType::TextMarkdown);
893    }
894
895    #[test]
896    fn test_detect_extension_images() {
897        assert_eq!(detect_mime_from_extension("png"), MimeType::ImagePng);
898        assert_eq!(detect_mime_from_extension("jpg"), MimeType::ImageJpeg);
899        assert_eq!(detect_mime_from_extension("jpeg"), MimeType::ImageJpeg);
900        assert_eq!(detect_mime_from_extension("gif"), MimeType::ImageGif);
901        assert_eq!(detect_mime_from_extension("bmp"), MimeType::ImageBmp);
902        assert_eq!(detect_mime_from_extension("svg"), MimeType::ImageSvg);
903        assert_eq!(detect_mime_from_extension("ppm"), MimeType::ImagePpm);
904    }
905
906    #[test]
907    fn test_detect_extension_archives() {
908        assert_eq!(detect_mime_from_extension("zip"), MimeType::ApplicationZip);
909        assert_eq!(detect_mime_from_extension("tar"), MimeType::ApplicationTar);
910        assert_eq!(detect_mime_from_extension("gz"), MimeType::ApplicationGzip);
911    }
912
913    #[test]
914    fn test_detect_extension_unknown() {
915        assert_eq!(detect_mime_from_extension("xyz"), MimeType::Unknown);
916        assert_eq!(detect_mime_from_extension(""), MimeType::Unknown);
917    }
918
919    // -- Magic byte detection -----------------------------------------------
920
921    #[test]
922    fn test_magic_png() {
923        let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
924        assert_eq!(
925            MimeDatabase::detect_mime("unknown", Some(&bytes)),
926            MimeType::ImagePng
927        );
928    }
929
930    #[test]
931    fn test_magic_jpeg() {
932        let bytes = [0xFF, 0xD8, 0xFF, 0xE0];
933        assert_eq!(
934            MimeDatabase::detect_mime("unknown", Some(&bytes)),
935            MimeType::ImageJpeg
936        );
937    }
938
939    #[test]
940    fn test_magic_gif() {
941        let bytes = b"GIF89a";
942        assert_eq!(
943            MimeDatabase::detect_mime("unknown", Some(bytes)),
944            MimeType::ImageGif
945        );
946    }
947
948    #[test]
949    fn test_magic_elf() {
950        let bytes = [0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01];
951        assert_eq!(
952            MimeDatabase::detect_mime("unknown", Some(&bytes)),
953            MimeType::ApplicationElf
954        );
955    }
956
957    #[test]
958    fn test_magic_pdf() {
959        let bytes = b"%PDF-1.7";
960        assert_eq!(
961            MimeDatabase::detect_mime("unknown", Some(bytes)),
962            MimeType::ApplicationPdf
963        );
964    }
965
966    #[test]
967    fn test_magic_gzip() {
968        let bytes = [0x1F, 0x8B, 0x08, 0x00];
969        assert_eq!(
970            MimeDatabase::detect_mime("unknown", Some(&bytes)),
971            MimeType::ApplicationGzip
972        );
973    }
974
975    #[test]
976    fn test_magic_zip() {
977        let bytes = [0x50, 0x4B, 0x03, 0x04];
978        assert_eq!(
979            MimeDatabase::detect_mime("unknown", Some(&bytes)),
980            MimeType::ApplicationZip
981        );
982    }
983
984    #[test]
985    fn test_magic_bmp() {
986        let bytes = [0x42, 0x4D, 0x00, 0x00];
987        assert_eq!(
988            MimeDatabase::detect_mime("unknown", Some(&bytes)),
989            MimeType::ImageBmp
990        );
991    }
992
993    #[test]
994    fn test_magic_wav() {
995        let mut bytes = [0u8; 16];
996        bytes[0..4].copy_from_slice(b"RIFF");
997        bytes[8..12].copy_from_slice(b"WAVE");
998        assert_eq!(
999            MimeDatabase::detect_mime("unknown", Some(&bytes)),
1000            MimeType::AudioWav
1001        );
1002    }
1003
1004    #[test]
1005    fn test_magic_ogg() {
1006        let bytes = b"OggS\x00\x02";
1007        assert_eq!(
1008            MimeDatabase::detect_mime("unknown", Some(bytes)),
1009            MimeType::AudioOgg
1010        );
1011    }
1012
1013    #[test]
1014    fn test_magic_ppm() {
1015        let bytes = b"P6\n640 480\n255\n";
1016        assert_eq!(
1017            MimeDatabase::detect_mime("unknown", Some(bytes)),
1018            MimeType::ImagePpm
1019        );
1020    }
1021
1022    #[test]
1023    fn test_magic_priority_over_extension() {
1024        // ELF binary with a .txt extension -- magic bytes should win
1025        let elf_bytes = [0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01];
1026        assert_eq!(
1027            MimeDatabase::detect_mime("sneaky.txt", Some(&elf_bytes)),
1028            MimeType::ApplicationElf
1029        );
1030    }
1031
1032    #[test]
1033    fn test_extension_fallback_when_no_magic() {
1034        assert_eq!(
1035            MimeDatabase::detect_mime("script.py", None),
1036            MimeType::TextPython
1037        );
1038    }
1039
1040    // -- Application dispatch -----------------------------------------------
1041
1042    #[test]
1043    fn test_open_with_defaults() {
1044        let db = MimeDatabase::new();
1045
1046        let assoc = db.open_with(&MimeType::TextPlain).unwrap();
1047        assert_eq!(assoc.app_name, "Text Editor");
1048
1049        let assoc = db.open_with(&MimeType::ImagePng).unwrap();
1050        assert_eq!(assoc.app_name, "Image Viewer");
1051
1052        let assoc = db.open_with(&MimeType::ApplicationElf).unwrap();
1053        assert_eq!(assoc.app_name, "Terminal");
1054
1055        let assoc = db.open_with(&MimeType::DirectoryType).unwrap();
1056        assert_eq!(assoc.app_name, "File Manager");
1057    }
1058
1059    #[test]
1060    fn test_custom_association_overrides_default() {
1061        let mut db = MimeDatabase::new();
1062
1063        // Override TextPlain to use a custom editor
1064        db.register_association(
1065            MimeType::TextPlain,
1066            String::from("Custom Editor"),
1067            String::from("/bin/custom-editor"),
1068        );
1069
1070        let assoc = db.open_with(&MimeType::TextPlain).unwrap();
1071        assert_eq!(assoc.app_name, "Custom Editor");
1072        assert_eq!(assoc.app_exec, "/bin/custom-editor");
1073    }
1074
1075    #[test]
1076    fn test_custom_association_replaces_existing() {
1077        let mut db = MimeDatabase::new();
1078
1079        db.register_association(
1080            MimeType::ImagePng,
1081            String::from("Viewer A"),
1082            String::from("/bin/a"),
1083        );
1084        db.register_association(
1085            MimeType::ImagePng,
1086            String::from("Viewer B"),
1087            String::from("/bin/b"),
1088        );
1089
1090        let assoc = db.open_with(&MimeType::ImagePng).unwrap();
1091        assert_eq!(assoc.app_name, "Viewer B");
1092    }
1093
1094    // -- Category / classification ------------------------------------------
1095
1096    #[test]
1097    fn test_category() {
1098        assert_eq!(
1099            MimeDatabase::category(&MimeType::TextRust),
1100            MimeCategory::Text
1101        );
1102        assert_eq!(
1103            MimeDatabase::category(&MimeType::ImagePng),
1104            MimeCategory::Image
1105        );
1106        assert_eq!(
1107            MimeDatabase::category(&MimeType::AudioWav),
1108            MimeCategory::Audio
1109        );
1110        assert_eq!(
1111            MimeDatabase::category(&MimeType::VideoMp4),
1112            MimeCategory::Video
1113        );
1114        assert_eq!(
1115            MimeDatabase::category(&MimeType::ApplicationElf),
1116            MimeCategory::Application,
1117        );
1118        assert_eq!(
1119            MimeDatabase::category(&MimeType::DirectoryType),
1120            MimeCategory::Directory,
1121        );
1122    }
1123
1124    #[test]
1125    fn test_mime_to_str() {
1126        assert_eq!(
1127            MimeDatabase::mime_to_str(&MimeType::TextPlain),
1128            "text/plain"
1129        );
1130        assert_eq!(MimeDatabase::mime_to_str(&MimeType::ImagePng), "image/png");
1131        assert_eq!(
1132            MimeDatabase::mime_to_str(&MimeType::ApplicationElf),
1133            "application/x-elf"
1134        );
1135        assert_eq!(
1136            MimeDatabase::mime_to_str(&MimeType::Unknown),
1137            "application/octet-stream"
1138        );
1139    }
1140
1141    // -- MimeType convenience methods ---------------------------------------
1142
1143    #[test]
1144    fn test_mimetype_helpers() {
1145        assert!(MimeType::TextRust.is_text());
1146        assert!(!MimeType::TextRust.is_image());
1147        assert!(MimeType::ImagePng.is_image());
1148        assert!(MimeType::AudioMp3.is_audio());
1149        assert!(MimeType::VideoMp4.is_video());
1150        assert!(MimeType::DirectoryType.is_directory());
1151        assert!(MimeType::ApplicationElf.is_executable());
1152        assert!(MimeType::ApplicationZip.is_archive());
1153        assert!(MimeType::ApplicationTar.is_archive());
1154        assert!(MimeType::ApplicationGzip.is_archive());
1155    }
1156
1157    // -- Icon color ---------------------------------------------------------
1158
1159    #[test]
1160    fn test_icon_color_nonzero() {
1161        // Every MIME type should have a non-zero icon color
1162        let types = [
1163            MimeType::TextPlain,
1164            MimeType::TextRust,
1165            MimeType::ImagePng,
1166            MimeType::AudioWav,
1167            MimeType::VideoMp4,
1168            MimeType::ApplicationElf,
1169            MimeType::DirectoryType,
1170            MimeType::Unknown,
1171        ];
1172        for t in &types {
1173            assert_ne!(MimeDatabase::icon_color(t), 0);
1174        }
1175    }
1176}