Skip to main content

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