mrmime 0.0.2

Small, explicit MIME type registry with fast lookup by type or extension.
//! [MIME Types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)
//!
//! A collection of common MIME types and their associated file extensions.
//! Different from other crates, this one only parses the **registered** MIME
//! types. Use `register_mime!` macro.
//!
//! Usage:
//! ```
//! use mrmime::{register_mime, MimeType};
//!
//! let m: MimeType = "text/html; charset=UTF-8".parse().unwrap(); // ok (registered)
//! assert_eq!(m.as_str(), "text/html");
//! assert_eq!(m.extension(), "html");
//! assert_eq!(m.extensions(), &["html", "htm"]);
//! assert_eq!(m.to_string(), "text/html");
//! assert_eq!(MimeType::from_extension("HTM").unwrap(), m); // case-insensitive
//! ```

use linkme::distributed_slice;
use std::{
    collections::HashMap,
    error::Error,
    fmt::{Display, Formatter},
    hash::Hash,
    str::FromStr,
    sync::OnceLock,
};

#[cfg_attr(feature = "diesel", derive(diesel::AsExpression, diesel::FromSqlRow))]
#[cfg_attr(feature = "diesel", diesel(sql_type = diesel::sql_types::Text))]
#[derive(Clone, Copy, Debug)]
pub struct MimeType {
    /// normalized "type/subtype[+suffix]" (lowercase, no params)
    pub mime: &'static str,
    /// all extensions; first is canonical
    pub exts: &'static [&'static str],
}

impl MimeType {
    #[inline]
    pub fn as_str(self) -> &'static str {
        self.mime
    }

    /// Returns all extensions for this MIME type.
    #[inline]
    pub fn extensions(self) -> &'static [&'static str] {
        self.exts
    }

    /// Returns the canonical extension for this MIME type (first one).
    #[inline]
    pub fn extension(self) -> &'static str {
        self.exts[0]
    }

    /// Lookup MIME type by file name extension (case-insensitive). First
    /// registered wins.
    pub fn from_extension(ext: &str) -> Option<Self> {
        ensure_built();
        let e = ext.trim().trim_start_matches('.').to_ascii_lowercase();
        EXT_TO_MIME.get().unwrap().get(e.as_str()).copied().copied()
    }
}

impl Display for MimeType {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.mime)
    }
}

// Equality/Hash by MIME string only (extensions are metadata)
impl PartialEq for MimeType {
    fn eq(&self, other: &Self) -> bool {
        self.mime == other.mime
    }
}
impl Eq for MimeType {}
impl Hash for MimeType {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.mime.hash(state)
    }
}

#[derive(Debug)]
pub enum ParseError {
    MissingSlash,
    EmptyType,
    EmptySubtype,
    NotRegistered,
}

impl Display for ParseError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            ParseError::MissingSlash => f.write_str("missing '/' separator"),
            ParseError::EmptyType => f.write_str("empty type"),
            ParseError::EmptySubtype => f.write_str("empty subtype"),
            ParseError::NotRegistered => f.write_str("MIME type not registered"),
        }
    }
}

impl Error for ParseError {}

impl FromStr for MimeType {
    type Err = ParseError;

    /// Strip params, lowercase, then look up in registry (registered-only).
    fn from_str(src: &str) -> Result<Self, Self::Err> {
        let head = src.split(';').next().unwrap_or("").trim();
        let (ty, rest) = head.split_once('/').ok_or(ParseError::MissingSlash)?;
        let ty = ty.trim();
        if ty.is_empty() {
            return Err(ParseError::EmptyType);
        }
        let rest = rest.trim();
        if rest.is_empty() {
            return Err(ParseError::EmptySubtype);
        }
        let norm = format!("{}/{}", ty.to_ascii_lowercase(), rest.to_ascii_lowercase());
        lookup_mime_str(&norm).ok_or(ParseError::NotRegistered)
    }
}

// ======================= Registry =======================
// Use a distributed slice of BLOCKS (slices), so each macro call emits ONE
// static.
#[distributed_slice]
#[doc(hidden)]
pub static MIMETYPE_BLOCKS: [&'static [MimeType]] = [..];

// Built maps (once): ext -> &MimeType, mime_str -> &MimeType
static EXT_TO_MIME: OnceLock<HashMap<&'static str, &'static MimeType>> = OnceLock::new();
static MIME_TO_MIME: OnceLock<HashMap<&'static str, &'static MimeType>> = OnceLock::new();

fn ensure_built() {
    if EXT_TO_MIME.get().is_some() {
        return;
    }

    MIME_TO_MIME.get_or_init(|| {
        let mut m = HashMap::new();
        for block in MIMETYPE_BLOCKS {
            for e in *block {
                let prev = m.insert(e.mime, e);
                assert!(prev.is_none(), "duplicate MIME '{}'", e.mime);
            }
        }
        m
    });

    EXT_TO_MIME.get_or_init(|| {
        let mut m = HashMap::new();
        for block in MIMETYPE_BLOCKS {
            for e in *block {
                assert!(
                    !e.exts.is_empty(),
                    "MIME '{}' must have at least one extension",
                    e.mime
                );
                for &ext in e.exts {
                    // Multiple MIME types can share the same extension:
                    // keep the FIRST registered one for MimeType::from_extension.
                    m.entry(ext).or_insert(e);
                }
            }
        }
        m
    });
}

#[inline]
fn lookup_mime_str(norm: &str) -> Option<MimeType> {
    ensure_built();
    MIME_TO_MIME.get().unwrap().get(norm).copied().copied()
}

/// Register MIME ↔ extensions in a unique module to avoid symbol collisions.
/// First extension is canonical. Multiple invocations across crates are merged.
///
/// Example:
/// ```
/// use mrmime::register_mime;
/// register_mime! { mod builtin {
///   "text/html"        => [html, htm];
///   "application/json" => [json, map];
/// } }
/// ```
#[doc(hidden)]
#[macro_export]
macro_rules! __mrmime_ext_to_str {
    ($e:ident) => {
        stringify!($e)
    };
    ($e:literal) => {
        $e
    };
}

#[macro_export]
macro_rules! register_mime {
    (mod $modname:ident { $( $mime:literal => [ $( $ext:tt ),+ $(,)? ] ; )+ } ) => {
        #[allow(non_snake_case)]
        mod $modname {
            #[linkme::distributed_slice($crate::MIMETYPE_BLOCKS)]
            pub static __BLOCK: &'static [$crate::MimeType] = &[
                $(
                    $crate::MimeType { mime: $mime, exts: &[ $( $crate::__mrmime_ext_to_str!($ext) ),+ ] },
                )+
            ];
        }
    };
}

#[cfg(feature = "builtin")]
pub mod builtin;

#[cfg(feature = "serde")]
mod serde_impl;

#[cfg(feature = "diesel")]
mod diesel_impl;