use std::fmt;
use serde::{Deserialize, Serialize};
pub mod cell;
pub mod check;
pub mod inbound;
pub mod inline_refs;
pub mod part;
pub mod probe;
pub mod registry;
pub mod store;
pub use cell::RegistryCell;
pub use check::{FnCheck, MimeCheck};
pub use inbound::InboundPart;
pub use part::{Blob, BlobRef, Delivery, Part, PartBody};
pub use registry::{MimeInfo, MimeRegistry, TokenRule};
pub use store::{BlobStore, MemoryBlobStore, is_sha256_hex, sha256_hex, verify_blob};
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct MimeType(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MimeTypeError {
#[error("'{0}' is not a mime type: expected type/subtype")]
Shape(String),
#[error("'{0}' carries a parameter; mime types here are bare type/subtype")]
Parameter(String),
#[error("'{0}' has a character that cannot appear in a mime type")]
Character(String),
}
impl MimeType {
pub fn parse(raw: &str) -> Result<Self, MimeTypeError> {
let trimmed = raw.trim();
if trimmed.contains(';') {
return Err(MimeTypeError::Parameter(raw.to_string()));
}
let lower = trimmed.to_ascii_lowercase();
let Some((kind, sub)) = lower.split_once('/') else {
return Err(MimeTypeError::Shape(raw.to_string()));
};
if kind.is_empty() || sub.is_empty() || sub.contains('/') {
return Err(MimeTypeError::Shape(raw.to_string()));
}
let ok = |c: char| c.is_ascii_alphanumeric() || "!#$&-^_.+*".contains(c);
if !lower.chars().all(|c| c == '/' || ok(c)) {
return Err(MimeTypeError::Character(raw.to_string()));
}
Ok(Self(lower))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn kind(&self) -> &str {
self.0.split_once('/').map_or(self.0.as_str(), |(k, _)| k)
}
pub fn subtype(&self) -> &str {
self.0.split_once('/').map_or("", |(_, s)| s)
}
pub fn matches(&self, pattern: &str) -> bool {
let pattern = pattern.trim().to_ascii_lowercase();
match pattern.split_once('/') {
Some(("*", "*")) => true,
Some((kind, "*")) => self.kind() == kind,
Some(_) => self.0 == pattern,
None => false,
}
}
pub fn matches_any<S: AsRef<str>>(&self, patterns: &[S]) -> bool {
patterns.iter().any(|p| self.matches(p.as_ref()))
}
pub fn family_pattern(&self) -> String {
format!("{}/*", self.kind())
}
}
impl fmt::Display for MimeType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Debug for MimeType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "MimeType({})", self.0)
}
}
impl TryFrom<String> for MimeType {
type Error = MimeTypeError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl TryFrom<&str> for MimeType {
type Error = MimeTypeError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<MimeType> for String {
fn from(value: MimeType) -> Self {
value.0
}
}
impl std::str::FromStr for MimeType {
type Err = MimeTypeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
pub fn text_plain() -> MimeType {
MimeType("text/plain".to_string())
}
pub fn octet_stream() -> MimeType {
MimeType("application/octet-stream".to_string())
}
pub fn human_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
let b = bytes as f64;
if bytes < 1024 {
format!("{bytes} B")
} else if b < KB * KB {
format!("{:.0} KB", b / KB)
} else if b < KB * KB * KB {
format!("{:.1} MB", b / KB / KB)
} else {
format!("{:.2} GB", b / KB / KB / KB)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_normalises() {
let t = MimeType::parse(" Image/PNG ").unwrap();
assert_eq!(t.as_str(), "image/png");
assert_eq!(t.kind(), "image");
assert_eq!(t.subtype(), "png");
assert_eq!(t.to_string(), "image/png");
assert_eq!(format!("{t:?}"), "MimeType(image/png)");
assert_eq!(t.family_pattern(), "image/*");
}
#[test]
fn rejects_bad_shapes() {
let shape = |s: &str| Err(MimeTypeError::Shape(s.to_string()));
assert_eq!(MimeType::parse("png"), shape("png"));
assert_eq!(MimeType::parse("/png"), shape("/png"));
assert_eq!(MimeType::parse("image/"), shape("image/"));
assert_eq!(MimeType::parse("a/b/c"), shape("a/b/c"));
assert_eq!(
MimeType::parse("text/plain; charset=utf-8"),
Err(MimeTypeError::Parameter(
"text/plain; charset=utf-8".to_string()
))
);
assert_eq!(
MimeType::parse("image/p ng"),
Err(MimeTypeError::Character("image/p ng".to_string()))
);
let err = MimeType::parse("png").unwrap_err();
assert!(err.to_string().contains("type/subtype"));
assert!(
MimeType::parse("a;b")
.unwrap_err()
.to_string()
.contains("parameter")
);
assert!(
MimeType::parse("a/b c")
.unwrap_err()
.to_string()
.contains("character")
);
}
#[test]
fn wildcard_matching() {
let t = MimeType::parse("image/png").unwrap();
assert!(t.matches("image/png"));
assert!(t.matches("IMAGE/*"));
assert!(t.matches("*/*"));
assert!(!t.matches("audio/*"));
assert!(!t.matches("image/jpeg"));
assert!(!t.matches("image"));
assert!(t.matches_any(&["audio/*", "image/*"]));
assert!(!t.matches_any::<&str>(&[]));
}
#[test]
fn serde_round_trip_and_conversions() {
let t: MimeType = serde_json::from_str("\"Audio/WAV\"").unwrap();
assert_eq!(t.as_str(), "audio/wav");
assert_eq!(serde_json::to_string(&t).unwrap(), "\"audio/wav\"");
assert!(serde_json::from_str::<MimeType>("\"nope\"").is_err());
let s: String = t.clone().into();
assert_eq!(s, "audio/wav");
let via_str: MimeType = "video/mp4".try_into().unwrap();
assert_eq!(via_str.kind(), "video");
let via_string: MimeType = String::from("model/obj").try_into().unwrap();
assert_eq!(via_string.subtype(), "obj");
let parsed: MimeType = "text/markdown".parse().unwrap();
assert_eq!(parsed, MimeType::parse("text/markdown").unwrap());
assert_eq!(text_plain().as_str(), "text/plain");
assert_eq!(octet_stream().as_str(), "application/octet-stream");
assert!(via_str < via_string || via_string < via_str);
}
#[test]
fn human_sizes() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(240 * 1024), "240 KB");
assert_eq!(human_size(18 * 1024 * 1024 + 200 * 1024), "18.2 MB");
assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.00 GB");
}
}