media_type_version/
lib.rs

1#![deny(missing_docs)]
2#![deny(clippy::missing_docs_in_private_items)]
3#![no_std]
4// SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
5// SPDX-License-Identifier: BSD-2-Clause
6//! media-type-version - extract the format version from a media type string
7//!
8//! ## Overview
9//!
10//! The `media-type-version` library is designed to be used as the first step in
11//! parsing structured data, e.g. configuration files, serialized classes, etc.
12//! The caller extracts the media type string (e.g. a JSON `"mediaType": "..."` key) and
13//! passes it in for parsing.
14//! The caller then decides what to do with the extracted version information -
15//! is this version supported, what fields are expected to be there, should any
16//! extraneous fields produce errors, and so on.
17//!
18//! The main entry point is the [`extract`] function which is passed two parameters:
19//! a [`Config`] object defining the expected media type prefix and suffix, and
20//! a media type string to parse.
21//! On success, it returns a [`Version`] object, basically a tuple of a major and
22//! minor version numbers.
23//!
24//! ## Media type string format
25//!
26//! The media type string is expected to be in a `<prefix>.vX.Y<suffix>` format, with
27//! a fixed prefix and suffix.
28//! The prefix will usually be a vendor-specific media type.
29//! The version part consists of two unsigned integer numbers.
30//! The suffix, if used, may correspond to the file format.
31//!
32//! A sample media type string identifying a TOML configuration file for
33//! a text-processing program could be
34//! `vnd.ringlet.textproc.publync.config/publync.v0.2+toml`
35//!
36//! ## Crate features
37//!
38//! - `alloc` - enable the [`Error::into_owned_error`] method
39//! - `facet-unstable` - [`Config`] and [`Version`] will derive from `Facet` so that
40//!   they can be examined or serialized that way.
41//!
42//!   Note that the `facet-unstable` feature adds a pinned version dependency on
43//!   whatever version of the `facet` crate is current at the time of release of
44//!   the `media-type-version` crate!
45//!   If this feature is activated, it is strongly recommended to have a pinned
46//!   version dependency on `media-type-version` itself!
47
48#![doc(html_root_url = "https://docs.rs/media-type-version/0.1.2")]
49#![expect(clippy::pub_use, reason = "re-export common symbols")]
50
51use core::str::FromStr as _;
52
53use log::debug;
54
55pub mod defs;
56
57pub use defs::{Config, Error, Version};
58
59/// Extract the format version from a media type string.
60///
61/// # Errors
62///
63/// [`Error::NoPrefix`], [`Error::NoSuffix`], [`Error::NoVDot`] if
64/// the media type string does not contain the required string parts at all.
65///
66/// [`Error::TwoComponentsExpected`] if the version part does not consist of
67/// exactly two dot-separated components.
68///
69/// [`Error::UIntExpected`] if those components are not unsigned integers.
70#[inline]
71pub fn extract<'data>(
72    cfg: &'data Config<'data>,
73    value: &'data str,
74) -> Result<Version, Error<'data>> {
75    debug!(
76        "Parsing a media type string '{value}', expecting prefix '{prefix}' and suffix '{suffix}'",
77        prefix = cfg.prefix(),
78        suffix = cfg.suffix()
79    );
80    let no_prefix = value
81        .strip_prefix(cfg.prefix())
82        .ok_or_else(|| Error::NoPrefix(value, cfg.prefix()))?;
83    let no_suffix = no_prefix
84        .strip_suffix(cfg.suffix())
85        .ok_or_else(|| Error::NoSuffix(value, cfg.suffix()))?;
86    let no_vdot = no_suffix
87        .strip_prefix(".v")
88        .ok_or(Error::NoVDot(no_suffix))?;
89    let (first, second) = {
90        let mut parts_it = no_vdot.split('.');
91        let first = parts_it.next().ok_or(Error::TwoComponentsExpected(value))?;
92        let second = parts_it.next().ok_or(Error::TwoComponentsExpected(value))?;
93        if parts_it.next().is_some() {
94            return Err(Error::TwoComponentsExpected(value));
95        }
96        (first, second)
97    };
98    let major = u32::from_str(first).map_err(|err| Error::UIntExpected(value, first, err))?;
99    let minor = u32::from_str(second).map_err(|err| Error::UIntExpected(value, second, err))?;
100    Ok(Version::from((major, minor)))
101}
102
103#[cfg(test)]
104mod tests {
105    extern crate alloc;
106
107    use alloc::format;
108    use alloc::string::String;
109
110    use eyre::WrapErr as _;
111    use facet_testhelpers::test;
112
113    #[cfg(feature = "facet-unstable")]
114    use facet_pretty::FacetPretty as _;
115
116    use crate::{Config, Error, Version};
117
118    /// The prefix and suffix to use for testing.
119    static CFG: Config<'_> = Config::from_parts("this/and", "+that");
120
121    #[cfg(feature = "facet-unstable")]
122    fn pretty_res(res: &Result<Version, Error<'_>>) -> String {
123        match *res {
124            Ok(ref ver) => format!("OK: {ver}", ver = ver.pretty()),
125            Err(ref err) => format!("Error: {err}"),
126        }
127    }
128
129    #[cfg(not(feature = "facet-unstable"))]
130    fn pretty_res(res: &Result<Version, Error<'_>>) -> String {
131        match *res {
132            Ok(ref ver) => format!(
133                "OK: Version {{ major: {major}, minor: {minor} }}",
134                major = ver.major(),
135                minor = ver.minor(),
136            ),
137            Err(ref err) => format!("Error: {err}"),
138        }
139    }
140
141    /// Make sure [extract][crate::extract] fails on invalid prefix.
142    #[test]
143    fn extract_fail_no_prefix() {
144        let res = crate::extract(&CFG, "nothing");
145        assert!(
146            matches!(res, Err(Error::NoPrefix(_, _))),
147            "expected Error::NoPrefix, got {res}",
148            res = pretty_res(&res)
149        );
150    }
151
152    /// Make sure [extract][crate::extract] fails on invalid suffix.
153    #[test]
154    fn extract_fail_no_suffix() {
155        let res = crate::extract(&CFG, "this/andnothing");
156        assert!(
157            matches!(res, Err(Error::NoSuffix(_, _))),
158            "expected Error::NoSuffix, got {res}",
159            res = pretty_res(&res)
160        );
161    }
162
163    /// Make sure [extract][crate::extract] fails on missing "v.".
164    #[test]
165    fn extract_fail_no_vdot() {
166        let res = crate::extract(&CFG, "this/andnothing+that");
167        assert!(
168            matches!(res, Err(Error::NoVDot(_))),
169            "expected Error::NoVDot, got {res}",
170            res = pretty_res(&res)
171        );
172    }
173
174    /// Make sure [extract][crate::extract] fails if no two components.
175    #[test]
176    fn extract_fail_two_expected() {
177        let res = crate::extract(&CFG, "this/and.vnothing+that");
178        assert!(
179            matches!(res, Err(Error::TwoComponentsExpected(_))),
180            "expected Error::TwoComponentsExpected, got {res}",
181            res = pretty_res(&res)
182        );
183    }
184
185    /// Make sure [extract][crate::extract] fails if not unsigned integers.
186    #[test]
187    fn extract_fail_uint_expected() {
188        let res_first = crate::extract(&CFG, "this/and.va.42+that");
189        assert!(
190            matches!(res_first, Err(Error::UIntExpected(_, _, _))),
191            "expected Error::UIntExpected, got {res_first}",
192            res_first = pretty_res(&res_first)
193        );
194
195        let res_second = crate::extract(&CFG, "this/and.v42.+that");
196        assert!(
197            matches!(res_second, Err(Error::UIntExpected(_, _, _))),
198            "expected Error::UIntExpected, got {res_second}",
199            res_second = pretty_res(&res_second)
200        );
201    }
202
203    /// Make sure [extract][crate::extract] succeeds on trivial correct data.
204    #[test]
205    fn extract_ok() {
206        let ver = crate::extract(&CFG, "this/and.v616.42+that").context("extract")?;
207        assert_eq!(ver.as_tuple(), (616, 42));
208    }
209}