spin_serde/
id.rs

1//! ID (de)serialization
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// An ID is a non-empty string containing one or more component model
7/// `word`s separated by a delimiter char.
8#[derive(
9    Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, JsonSchema,
10)]
11#[serde(into = "String", try_from = "String")]
12pub struct Id<const DELIM: char, const LOWER: bool>(String);
13
14impl<const DELIM: char, const LOWER: bool> std::fmt::Display for Id<DELIM, LOWER> {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        write!(f, "{}", self.0)
17    }
18}
19
20impl<const DELIM: char, const LOWER: bool> AsRef<str> for Id<DELIM, LOWER> {
21    fn as_ref(&self) -> &str {
22        &self.0
23    }
24}
25
26impl<const DELIM: char, const LOWER: bool> From<Id<DELIM, LOWER>> for String {
27    fn from(value: Id<DELIM, LOWER>) -> Self {
28        value.0
29    }
30}
31
32impl<const DELIM: char, const LOWER: bool> TryFrom<String> for Id<DELIM, LOWER> {
33    type Error = String;
34
35    fn try_from(id: String) -> Result<Self, Self::Error> {
36        if id.is_empty() {
37            return Err("empty".into());
38        }
39        // Special-case common "wrong separator" errors
40        if let Some(wrong) = wrong_delim::<DELIM>() {
41            if id.contains(wrong) {
42                return Err(format!(
43                    "words must be separated with {DELIM:?}, not {wrong:?}"
44                ));
45            }
46        }
47        for word in id.split(DELIM) {
48            if word.is_empty() {
49                return Err(format!("{DELIM:?}-separated words must not be empty"));
50            }
51            let mut chars = word.chars();
52            let first = chars.next().unwrap();
53            if !first.is_ascii_alphabetic() {
54                return Err(format!(
55                    "{DELIM:?}-separated words must start with an ASCII letter; got {first:?}"
56                ));
57            }
58            let word_is_uppercase = first.is_ascii_uppercase();
59            for ch in chars {
60                if ch.is_ascii_digit() {
61                } else if !ch.is_ascii_alphanumeric() {
62                    return Err(format!(
63                        "{DELIM:?}-separated words may only contain alphanumeric ASCII; got {ch:?}"
64                    ));
65                } else if ch.is_ascii_uppercase() != word_is_uppercase {
66                    return Err(format!("{DELIM:?}-separated words must be all lowercase or all UPPERCASE; got {word:?}"));
67                }
68            }
69            if LOWER && word_is_uppercase {
70                return Err(format!(
71                    "Lower-case identifiers must be all lowercase; got {id:?}"
72                ));
73            }
74        }
75        Ok(Self(id))
76    }
77}
78
79const fn wrong_delim<const DELIM: char>() -> Option<char> {
80    match DELIM {
81        '_' => Some('-'),
82        '-' => Some('_'),
83        _ => None,
84    }
85}