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//! - `extract-from-table` - enable the [`Table`] trait and the [`extract_from_table`] method
40//! - `facet-unstable` - [`Config`] and [`Version`] will derive from `Facet` so that
41//!   they can be examined or serialized that way.
42//! - `toml-boml1` - implement the [`Table`] trait for the `TomlTable` type from
43//!   the `boml` crate so that [`extract_from_table`] may be used for values of that type.
44//!
45//!   Note that the `facet-unstable` feature adds a pinned version dependency on
46//!   whatever version of the `facet` crate is current at the time of release of
47//!   the `media-type-version` crate!
48//!   If this feature is activated, it is strongly recommended to have a pinned
49//!   version dependency on `media-type-version` itself!
50
51#![doc(html_root_url = "https://docs.rs/media-type-version/0.2.0")]
52#![expect(clippy::pub_use, reason = "re-export common symbols")]
53
54use core::str::FromStr as _;
55
56use log::debug;
57
58#[cfg(feature = "toml-boml1")]
59use boml::prelude::TomlGetError as TomlBomlGetError;
60
61#[cfg(feature = "toml-boml1")]
62use boml::table::TomlTable as TomlBomlTable;
63
64#[cfg(feature = "json-serialzero-unstable")]
65use serialzero::JsonValue;
66
67mod defs;
68
69pub use defs::{Config, ConfigBuilder, Error, Version};
70
71#[cfg(feature = "alloc")]
72pub use defs::OwnedError;
73
74/// The number of features that are always defined.
75const FEATURES_COUNT_BASE: usize = 2;
76
77#[cfg(not(feature = "extract-from-table"))]
78/// Do not add 1 if `extract-from-table` is not enabled.
79const FEATURES_COUNT_EXTRACT_FROM_TABLE: usize = 0;
80
81#[cfg(feature = "extract-from-table")]
82/// Add 1 if `extract-from-table` is enabled.
83const FEATURES_COUNT_EXTRACT_FROM_TABLE: usize = 1;
84
85/// The number of currently enabled features.
86const FEATURES_COUNT: usize = FEATURES_COUNT_BASE + FEATURES_COUNT_EXTRACT_FROM_TABLE;
87
88/// The features supported by this version of the library.
89pub const FEATURES: [(&str, &str); FEATURES_COUNT] = [
90    ("media-type-version", env!("CARGO_PKG_VERSION")),
91    ("extract", "0.1"),
92    #[cfg(feature = "extract-from-table")]
93    ("extract-from-table", "0.1"),
94];
95
96/// Extract the format version from a media type string.
97///
98/// # Errors
99///
100/// [`Error::NoPrefix`], [`Error::NoSuffix`], [`Error::NoVDot`] if
101/// the media type string does not contain the required string parts at all.
102///
103/// [`Error::TwoComponentsExpected`] if the version part does not consist of
104/// exactly two dot-separated components.
105///
106/// [`Error::UIntExpected`] if those components are not unsigned integers.
107#[inline]
108pub fn extract<'data>(
109    cfg: &'data Config<'data>,
110    value: &'data str,
111) -> Result<Version, Error<'data>> {
112    debug!(
113        "Parsing a media type string '{value}', expecting prefix '{prefix}' and suffix '{suffix}'",
114        prefix = cfg.prefix(),
115        suffix = cfg.suffix()
116    );
117    let no_prefix = value
118        .strip_prefix(cfg.prefix())
119        .ok_or_else(|| Error::NoPrefix(value, cfg.prefix()))?;
120    let no_suffix = no_prefix
121        .strip_suffix(cfg.suffix())
122        .ok_or_else(|| Error::NoSuffix(value, cfg.suffix()))?;
123    let no_vdot = no_suffix
124        .strip_prefix(".v")
125        .ok_or(Error::NoVDot(no_suffix))?;
126    let (first, second) = {
127        let mut parts_it = no_vdot.split('.');
128        let first = parts_it.next().ok_or(Error::TwoComponentsExpected(value))?;
129        let second = parts_it.next().ok_or(Error::TwoComponentsExpected(value))?;
130        if parts_it.next().is_some() {
131            return Err(Error::TwoComponentsExpected(value));
132        }
133        (first, second)
134    };
135    let major = u32::from_str(first).map_err(|err| Error::UIntExpected(value, first, err))?;
136    let minor = u32::from_str(second).map_err(|err| Error::UIntExpected(value, second, err))?;
137    Ok(Version::from((major, minor)))
138}
139
140#[cfg(feature = "extract-from-table")]
141/// A hierarchical structure that may contain at least child tables and strings.
142pub trait Table<'data> {
143    /// Is this particular element a key/value table?
144    fn is_table(&'data self) -> bool;
145
146    /// Get the child table with the specified key.
147    ///
148    /// # Errors
149    ///
150    /// [`Error::TableNoChild`] if there is no child by that name.
151    fn get_child_table(&'data self, name: &'data str) -> Result<&'data Self, Error<'data>>;
152
153    /// Get the child string slice with the specified key.
154    ///
155    /// # Errors
156    ///
157    /// [`Error::TableNoChild`] if there is no child by that name.
158    fn get_child_string(&'data self, name: &'data str) -> Result<&'data str, Error<'data>>;
159}
160
161#[cfg(feature = "extract-from-table")]
162/// Extract the media type version from a hierarchical data structure.
163///
164/// # Errors
165///
166/// [`Error::TableNotTable`] if either the top-level element or something that still has
167/// remaining path components is not a table.
168///
169/// [`Error::TableNoChild`] if some of the path components does not exist.
170#[inline]
171pub fn extract_from_table<'data, T>(
172    cfg: &'data Config<'data>,
173    mut value: &'data T,
174    path: &'data [&'data str],
175) -> Result<Version, Error<'data>>
176where
177    T: Table<'data>,
178{
179    if !value.is_table() {
180        return Err(Error::TableNotTable);
181    }
182    value = path
183        .iter()
184        .try_fold(value, |current_value, comp| -> Result<&T, Error<'data>> {
185            current_value.get_child_table(comp)
186        })?;
187    let media_type = value.get_child_string("mediaType")?;
188    extract(cfg, media_type)
189}
190
191#[cfg(feature = "json-serialzero-unstable")]
192impl<'data> Table<'data> for JsonValue {
193    #[inline]
194    fn is_table(&self) -> bool {
195        matches!(*self, Self::Object(_))
196    }
197
198    #[inline]
199    fn get_child_table(&'data self, name: &'data str) -> Result<&'data Self, Error<'data>> {
200        let Self::Object(ref map) = *self else {
201            return Err(Error::TableNotTable);
202        };
203        map.get(name).ok_or(Error::TableNoChild(name))
204    }
205
206    #[inline]
207    fn get_child_string(&'data self, name: &'data str) -> Result<&'data str, Error<'data>> {
208        let Self::Object(ref map) = *self else {
209            return Err(Error::TableNotTable);
210        };
211        let child = map.get(name).ok_or(Error::TableNoChild(name))?;
212        let Self::String(ref value) = *child else {
213            return Err(Error::TableNotTable);
214        };
215        Ok(value)
216    }
217}
218
219#[cfg(feature = "toml-boml1")]
220impl<'data> Table<'data> for TomlBomlTable<'data> {
221    #[inline]
222    fn is_table(&self) -> bool {
223        true
224    }
225
226    #[inline]
227    fn get_child_table(&'data self, name: &'data str) -> Result<&'data Self, Error<'data>> {
228        self.get_table(name).map_err(|err| match err {
229            TomlBomlGetError::InvalidKey => Error::TableNoChild(name),
230            TomlBomlGetError::TypeMismatch(_, _) => Error::TableNotTable,
231        })
232    }
233
234    #[inline]
235    fn get_child_string(&'data self, name: &'data str) -> Result<&'data str, Error<'data>> {
236        self.get_string(name).map_err(|err| match err {
237            TomlBomlGetError::InvalidKey => Error::TableNoChild(name),
238            TomlBomlGetError::TypeMismatch(_, _) => Error::TableNotTable,
239        })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    extern crate alloc;
246
247    use alloc::format;
248    use alloc::string::String;
249
250    use eyre::{Result, WrapErr as _};
251    use facet_testhelpers::test;
252
253    #[cfg(feature = "facet-unstable")]
254    use facet_pretty::FacetPretty as _;
255
256    use crate::{Config, Error, Version};
257
258    /// The prefix and suffix to use for testing.
259    static CFG: Config<'_> = Config::from_parts("this/and", "+that");
260
261    #[cfg(feature = "facet-unstable")]
262    fn pretty_res(res: &Result<Version, Error<'_>>) -> String {
263        match *res {
264            Ok(ref ver) => format!("OK: {ver}", ver = ver.pretty()),
265            Err(ref err) => format!("Error: {err}"),
266        }
267    }
268
269    #[cfg(not(feature = "facet-unstable"))]
270    fn pretty_res(res: &Result<Version, Error<'_>>) -> String {
271        match *res {
272            Ok(ref ver) => format!(
273                "OK: Version {{ major: {major}, minor: {minor} }}",
274                major = ver.major(),
275                minor = ver.minor(),
276            ),
277            Err(ref err) => format!("Error: {err}"),
278        }
279    }
280
281    /// Make sure [extract][crate::extract] fails on invalid prefix.
282    #[test]
283    fn extract_fail_no_prefix() {
284        let res = crate::extract(&CFG, "nothing");
285        assert!(
286            matches!(res, Err(Error::NoPrefix(_, _))),
287            "expected Error::NoPrefix, got {res}",
288            res = pretty_res(&res)
289        );
290    }
291
292    /// Make sure [extract][crate::extract] fails on invalid suffix.
293    #[test]
294    fn extract_fail_no_suffix() {
295        let res = crate::extract(&CFG, "this/andnothing");
296        assert!(
297            matches!(res, Err(Error::NoSuffix(_, _))),
298            "expected Error::NoSuffix, got {res}",
299            res = pretty_res(&res)
300        );
301    }
302
303    /// Make sure [extract][crate::extract] fails on missing "v.".
304    #[test]
305    fn extract_fail_no_vdot() {
306        let res = crate::extract(&CFG, "this/andnothing+that");
307        assert!(
308            matches!(res, Err(Error::NoVDot(_))),
309            "expected Error::NoVDot, got {res}",
310            res = pretty_res(&res)
311        );
312    }
313
314    /// Make sure [extract][crate::extract] fails if no two components.
315    #[test]
316    fn extract_fail_two_expected() {
317        let res = crate::extract(&CFG, "this/and.vnothing+that");
318        assert!(
319            matches!(res, Err(Error::TwoComponentsExpected(_))),
320            "expected Error::TwoComponentsExpected, got {res}",
321            res = pretty_res(&res)
322        );
323    }
324
325    /// Make sure [extract][crate::extract] fails if not unsigned integers.
326    #[test]
327    fn extract_fail_uint_expected() {
328        let res_first = crate::extract(&CFG, "this/and.va.42+that");
329        assert!(
330            matches!(res_first, Err(Error::UIntExpected(_, _, _))),
331            "expected Error::UIntExpected, got {res_first}",
332            res_first = pretty_res(&res_first)
333        );
334
335        let res_second = crate::extract(&CFG, "this/and.v42.+that");
336        assert!(
337            matches!(res_second, Err(Error::UIntExpected(_, _, _))),
338            "expected Error::UIntExpected, got {res_second}",
339            res_second = pretty_res(&res_second)
340        );
341    }
342
343    /// Make sure [extract][crate::extract] succeeds on trivial correct data.
344    #[test]
345    fn extract_ok() -> Result<()> {
346        let ver = crate::extract(&CFG, "this/and.v616.42+that").context("extract")?;
347        assert_eq!(ver.as_tuple(), (616, 42));
348        Ok(())
349    }
350}