voa_core/identifiers/
base.rs

1//! Base types used in other identifiers.
2
3use std::{fmt::Display, ops::Deref, str::FromStr};
4
5use winnow::{
6    ModalResult,
7    Parser,
8    combinator::{cut_err, eof, repeat},
9    error::StrContext,
10    token::one_of,
11};
12
13#[cfg(doc)]
14use crate::identifiers::{CustomContext, CustomRole, CustomTechnology, Os};
15use crate::iter_char_context;
16
17/// A string that represents a valid VOA identifier.
18///
19/// An [`IdentifierString`] is used e.g. in the components of [`Os`], [`CustomContext`],
20/// [`CustomRole`] or [`CustomTechnology`].
21/// It may only contain characters in the set of lowercase, alphanumeric ASCII characters, or
22/// the special characters `_`, `-` or `.`.
23#[derive(Clone, Debug, Eq, PartialEq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize))]
25pub struct IdentifierString(String);
26
27impl IdentifierString {
28    /// The list of allowed characters outside of the set of lowercase, alphanumeric ASCII
29    /// characters.
30    pub const SPECIAL_CHARS: &[char; 3] = &['_', '-', '.'];
31
32    /// A parser for characters valid in the context of an [`IdentifierString`].
33    ///
34    /// Consumes a single character from `input` and returns it.
35    /// The character in `input` must be in the set of lowercase, alphanumeric ASCII characters, or
36    /// one of the special characters [`IdentifierString::SPECIAL_CHARS`].
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if a character in `input` is not in the set of lowercase, alphanumeric
41    /// ASCII characters, or one of the special characters [`IdentifierString::SPECIAL_CHARS`].
42    pub fn valid_chars(input: &mut &str) -> ModalResult<char> {
43        one_of((
44            |c: char| c.is_ascii_lowercase(),
45            |c: char| c.is_ascii_digit(),
46            Self::SPECIAL_CHARS,
47        ))
48        .context(StrContext::Expected(
49            winnow::error::StrContextValue::Description("lowercase alphanumeric ASCII characters"),
50        ))
51        .context_with(iter_char_context!(Self::SPECIAL_CHARS))
52        .parse_next(input)
53    }
54
55    /// Recognizes an [`IdentifierString`] in a string slice.
56    ///
57    /// Relies on [`winnow`] to parse `input` and recognizes a valid [`IdentifierString`].
58    /// All characters in `input` must be in the set of lowercase, alphanumeric ASCII characters, or
59    /// the special characters `_`, `-` or `.`.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if `input` contains characters that are outside of the set of lowercase,
64    /// alphanumeric ASCII characters or the special characters `_`, `-` or `.`.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use voa_core::identifiers::IdentifierString;
70    /// use winnow::Parser;
71    ///
72    /// # fn main() -> Result<(), voa_core::Error> {
73    /// let id_string = "foo-123";
74    /// assert_eq!(
75    ///     id_string,
76    ///     IdentifierString::parser.parse(id_string)?.to_string(),
77    /// );
78    /// # Ok(())
79    /// # }
80    /// ```
81    pub fn parser(input: &mut &str) -> ModalResult<Self> {
82        let id_string = repeat::<_, _, (), _, _>(1.., Self::valid_chars)
83            .take()
84            .context(StrContext::Label("VOA identifier string"))
85            .parse_next(input)?;
86
87        cut_err(eof)
88            .context(StrContext::Label("VOA identifier string"))
89            .context(StrContext::Expected(
90                winnow::error::StrContextValue::Description(
91                    "lowercase alphanumeric ASCII characters",
92                ),
93            ))
94            .context_with(iter_char_context!(Self::SPECIAL_CHARS))
95            .parse_next(input)?;
96
97        Ok(Self(id_string.to_string()))
98    }
99
100    /// Extracts a string slice containing the entire [`String`].
101    pub fn as_str(&self) -> &str {
102        &self.0
103    }
104}
105
106impl AsRef<str> for IdentifierString {
107    fn as_ref(&self) -> &str {
108        &self.0
109    }
110}
111
112impl Deref for IdentifierString {
113    type Target = str;
114    fn deref(&self) -> &Self::Target {
115        self.0.deref()
116    }
117}
118
119impl Display for IdentifierString {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        write!(f, "{}", self.0)
122    }
123}
124
125impl FromStr for IdentifierString {
126    type Err = crate::Error;
127
128    /// Creates an [`IdentifierString`] from a string slice.
129    ///
130    /// # Note
131    ///
132    /// Delegates to [`IdentifierString::parser`].
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if [`IdentifierString::parser`] fails.
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        Ok(Self::parser.parse(s)?)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144
145    use rstest::rstest;
146    use testresult::TestResult;
147
148    use super::*;
149
150    #[rstest]
151    #[case::alpha("foo")]
152    #[case::alpha_numeric("foo123")]
153    #[case::alpha_numeric_special("foo-123")]
154    #[case::alpha_numeric_special("foo_123")]
155    #[case::alpha_numeric_special("foo.123")]
156    #[case::only_special_chars("._-")]
157    fn identifier_string_from_str_valid_chars(#[case] input: &str) -> TestResult {
158        match IdentifierString::from_str(input) {
159            Ok(id_string) => {
160                assert_eq!(id_string, IdentifierString(input.to_string()));
161                Ok(())
162            }
163            Err(error) => {
164                return Err(
165                    format!("Should have succeeded to parse {input} but failed: {error}").into(),
166                );
167            }
168        }
169    }
170
171    #[rstest]
172    #[case::empty_string("", "\n^")]
173    #[case::all_caps("FOO", "FOO\n^")]
174    #[case::one_caps("foO", "foO\n  ^")]
175    #[case::one_caps("foo:", "foo:\n   ^")]
176    #[case::one_caps("foö", "foö\n  ^")]
177    fn identifier_string_from_str_invalid_chars(
178        #[case] input: &str,
179        #[case] error_msg: &str,
180    ) -> TestResult {
181        match IdentifierString::from_str(input) {
182            Ok(id_string) => {
183                return Err(format!(
184                    "Should have failed to parse {input} but succeeded: {id_string}"
185                )
186                .into());
187            }
188            Err(error) => {
189                assert_eq!(
190                    error.to_string(),
191                    format!(
192                        "Parser error:\n{error_msg}\ninvalid VOA identifier string\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
193                    )
194                );
195                Ok(())
196            }
197        }
198    }
199}