1#![no_std]
33#![forbid(unsafe_code)]
34#![doc(html_root_url = "https://docs.rs/media-typer/0.1.0")]
35
36extern crate alloc;
37
38use alloc::string::{String, ToString};
39
40#[cfg(doctest)]
42#[doc = include_str!("../README.md")]
43struct ReadmeDoctests;
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51pub struct MediaType {
52 pub type_: String,
54 pub subtype: String,
56 pub suffix: Option<String>,
58}
59
60impl MediaType {
61 pub fn new(
64 type_: impl Into<String>,
65 subtype: impl Into<String>,
66 suffix: Option<impl Into<String>>,
67 ) -> Self {
68 Self {
69 type_: type_.into(),
70 subtype: subtype.into(),
71 suffix: suffix.map(Into::into),
72 }
73 }
74
75 pub fn format(&self) -> Result<String, Error> {
80 format(self)
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum Error {
87 InvalidMediaType(String),
89 InvalidType(String),
91 InvalidSubtype(String),
93 InvalidSuffix(String),
95}
96
97impl core::fmt::Display for Error {
98 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99 match self {
100 Error::InvalidMediaType(s) => write!(f, "invalid media type: {s}"),
101 Error::InvalidType(s) => write!(f, "invalid type: {s}"),
102 Error::InvalidSubtype(s) => write!(f, "invalid subtype: {s}"),
103 Error::InvalidSuffix(s) => write!(f, "invalid suffix: {s}"),
104 }
105 }
106}
107
108impl core::error::Error for Error {}
109
110pub fn parse(media_type: &str) -> Result<MediaType, Error> {
124 let (type_part, subtype_part) = match media_type.split_once('/') {
125 Some((t, sub)) if is_type_name(t) && is_subtype_name(sub) => (t, sub),
126 _ => return Err(Error::InvalidMediaType(media_type.to_string())),
127 };
128 let type_ = type_part.to_ascii_lowercase();
129 let mut subtype = subtype_part.to_ascii_lowercase();
130 let suffix = subtype.rfind('+').map(|idx| {
131 let suffix = subtype[idx + 1..].to_string();
132 subtype.truncate(idx);
133 suffix
134 });
135 Ok(MediaType {
136 type_,
137 subtype,
138 suffix,
139 })
140}
141
142pub fn format(media_type: &MediaType) -> Result<String, Error> {
157 if !is_type_name(&media_type.type_) {
158 return Err(Error::InvalidType(media_type.type_.clone()));
159 }
160 if !is_subtype_name(&media_type.subtype) {
161 return Err(Error::InvalidSubtype(media_type.subtype.clone()));
162 }
163 let mut out = String::with_capacity(media_type.type_.len() + media_type.subtype.len() + 8);
164 out.push_str(&media_type.type_);
165 out.push('/');
166 out.push_str(&media_type.subtype);
167 if let Some(suffix) = &media_type.suffix {
168 if !is_type_name(suffix) {
169 return Err(Error::InvalidSuffix(suffix.clone()));
170 }
171 out.push('+');
172 out.push_str(suffix);
173 }
174 Ok(out)
175}
176
177#[must_use]
185pub fn test(media_type: &str) -> bool {
186 match media_type.split_once('/') {
187 Some((type_, subtype)) => is_type_name(type_) && is_subtype_name(subtype),
188 None => false,
189 }
190}
191
192fn is_type_name(s: &str) -> bool {
194 let bytes = s.as_bytes();
195 match bytes.split_first() {
196 Some((first, rest)) if first.is_ascii_alphanumeric() && rest.len() <= 126 => {
197 rest.iter().all(|&b| is_type_char(b))
198 }
199 _ => false,
200 }
201}
202
203fn is_subtype_name(s: &str) -> bool {
206 let bytes = s.as_bytes();
207 match bytes.split_first() {
208 Some((first, rest)) if first.is_ascii_alphanumeric() && rest.len() <= 126 => {
209 rest.iter().all(|&b| is_subtype_char(b))
210 }
211 _ => false,
212 }
213}
214
215fn is_type_char(b: u8) -> bool {
216 b.is_ascii_alphanumeric() || matches!(b, b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'-')
217}
218
219fn is_subtype_char(b: u8) -> bool {
220 is_type_char(b) || b == b'.' || b == b'+'
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn parse_basic() {
229 assert_eq!(
230 parse("application/json").unwrap(),
231 MediaType::new("application", "json", None::<&str>)
232 );
233 assert_eq!(
234 parse("application/vnd.api+json").unwrap(),
235 MediaType::new("application", "vnd.api", Some("json"))
236 );
237 }
238
239 #[test]
240 fn parse_lowercases() {
241 assert_eq!(
242 parse("IMAGE/SVG+XML").unwrap(),
243 MediaType::new("image", "svg", Some("xml"))
244 );
245 }
246
247 #[test]
248 fn parse_suffix_at_last_plus() {
249 let mt = parse("application/a+b+c").unwrap();
250 assert_eq!(mt.subtype, "a+b");
251 assert_eq!(mt.suffix.as_deref(), Some("c"));
252 }
253
254 #[test]
255 fn parse_degenerate_empty_suffix() {
256 let mt = parse("application/x++").unwrap();
257 assert_eq!(mt.subtype, "x+");
258 assert_eq!(mt.suffix.as_deref(), Some(""));
259 assert_eq!(mt.format(), Err(Error::InvalidSuffix(String::new())));
261 }
262
263 #[test]
264 fn parse_rejects_invalid() {
265 for bad in [
266 "bogus",
267 "a/b/c",
268 "",
269 ".a/b",
270 "a/.b",
271 "text/html; q=1",
272 "a /b",
273 "/",
274 ] {
275 assert!(parse(bad).is_err(), "{bad:?} should be invalid");
276 }
277 }
278
279 #[test]
280 fn test_function() {
281 assert!(test("text/html"));
282 assert!(test("application/vnd.api+json"));
283 assert!(!test("text/html;x=1"));
284 assert!(!test("text/html "));
285 assert!(!test("text"));
286 }
287
288 #[test]
289 fn format_basic_and_errors() {
290 assert_eq!(
291 format(&MediaType::new("application", "json", None::<&str>)).unwrap(),
292 "application/json"
293 );
294 assert_eq!(
295 format(&MediaType::new("application", "vnd.api", Some("xml"))).unwrap(),
296 "application/vnd.api+xml"
297 );
298 assert_eq!(
299 format(&MediaType::new("a/b", "c", None::<&str>)),
300 Err(Error::InvalidType("a/b".to_string()))
301 );
302 assert_eq!(
303 format(&MediaType::new("a", "b", Some(""))),
304 Err(Error::InvalidSuffix(String::new()))
305 );
306 }
307
308 #[test]
309 fn length_limits() {
310 let ok = alloc::format!("a/{}", "b".repeat(127)); assert!(test(&ok));
312 let too_long = alloc::format!("a/{}", "b".repeat(128)); assert!(!test(&too_long));
314 }
315}