Skip to main content

substrate_data/model/
typed.rs

1//! The typed values' string forms — one grammar for every format:
2//!
3//! | Written | Value |
4//! |---|---|
5//! | `@file:images/logo.png`, `@user:alice` | [`Value::Ref`] |
6//! | `@bytes:iVBORw0…` (base64) | [`Value::Bytes`] |
7//! | `@@text` | the plain string `@text` |
8//!
9//! A string that starts with `@` is always one of these (or an error), in
10//! any field — so a typo in a reference is never silently plain text.
11
12use std::fmt;
13
14use base64::Engine as _;
15
16use crate::model::value::{KindName, Ref, Value, ValueKind};
17use crate::names::TYPED;
18
19/// The kind word of bytes written as text (`@bytes:…`); no reference has it.
20/// The kind name of bytes: `@bytes:<base64>`.
21pub(crate) const BYTES: &str = "bytes";
22
23/// What a string spells.
24#[derive(Debug, Clone, PartialEq)]
25pub enum Spelled {
26    /// Plain text (its `@@` escape undone).
27    Plain(String),
28    /// A reference.
29    Ref(Ref),
30    /// Bytes.
31    Bytes(Vec<u8>),
32}
33
34impl Spelled {
35    /// Which value it is, for messages.
36    #[must_use]
37    pub fn kind(&self) -> ValueKind {
38        match self {
39            Spelled::Plain(_) => ValueKind::Str,
40            Spelled::Ref(_) => ValueKind::Ref,
41            Spelled::Bytes(_) => ValueKind::Bytes,
42        }
43    }
44
45    /// As a value (plain text as spelled again: a leading `@` doubled).
46    #[must_use]
47    pub fn into_value(self) -> Value {
48        match self {
49            Spelled::Plain(s) => Value::Str(escape_at(&s)),
50            Spelled::Ref(r) => Value::Ref(r),
51            Spelled::Bytes(b) => Value::Bytes(b),
52        }
53    }
54}
55
56/// A string written as a typed value that isn't one.
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[expect(
59    clippy::module_name_repetitions,
60    reason = "re-exported at the crate root, where `Error` alone would say nothing"
61)]
62pub enum TypedError {
63    /// No `kind:` (`@logo`).
64    NoKind {
65        /// The string as written.
66        spelled: String,
67    },
68    /// A kind that isn't one (`@File:x`).
69    BadKind {
70        /// The kind as written.
71        kind: String,
72    },
73    /// An id that isn't one (`@file:a b`), of `kind` when it's known.
74    BadId {
75        /// Its reference's kind, when it's known.
76        kind: Option<KindName>,
77        /// The id as written.
78        id: String,
79    },
80    /// `@bytes:` that isn't base64.
81    BadBytes(base64::DecodeError),
82}
83
84impl fmt::Display for TypedError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            TypedError::NoKind { spelled } => write!(
88                f,
89                "`{spelled}`: a reference is `@kind:id` (`@file:images/logo.png`); write `@@` for a literal `@`"
90            ),
91            TypedError::BadKind { kind } => write!(
92                f,
93                "`{kind}` isn't a kind (lower case letters, digits and `_`: `user`, `doc`, `file`, ...)"
94            ),
95            TypedError::BadId {
96                kind: Some(kind),
97                id,
98            } => write!(
99                f,
100                "`@{kind}:{id}`: `{id}` isn't an id (not empty, no spaces or control characters)"
101            ),
102            TypedError::BadId { kind: None, id } => write!(
103                f,
104                "`{id}` isn't an id (not empty, no spaces or control characters)"
105            ),
106            TypedError::BadBytes(e) => write!(f, "`@bytes:`: not base64 ({e})"),
107        }
108    }
109}
110
111impl std::error::Error for TypedError {}
112
113/// What `s` spells: a typed value if it's written as one (`@…`), plain
114/// text otherwise; `@@` escapes a leading `@`.
115///
116/// # Errors
117///
118/// [`TypedError`] when `s` starts with `@` but isn't a typed value.
119pub fn classify(s: &str) -> Result<Spelled, TypedError> {
120    let Some(rest) = s.strip_prefix(TYPED) else {
121        return Ok(Spelled::Plain(s.to_string()));
122    };
123    if rest.starts_with(TYPED) {
124        return Ok(Spelled::Plain(rest.to_string()));
125    }
126    let (kind, id) = rest.split_once(':').ok_or_else(|| TypedError::NoKind {
127        spelled: s.to_owned(),
128    })?;
129    if kind == BYTES {
130        return base64::engine::general_purpose::STANDARD
131            .decode(id)
132            .map(Spelled::Bytes)
133            .map_err(TypedError::BadBytes);
134    }
135    Ref::parse(kind, id).map(Spelled::Ref)
136}
137
138/// The string form of a typed value (`None` for the untyped ones).
139pub fn spelling(v: &Value) -> Option<String> {
140    Some(match v {
141        Value::Ref(r) => r.to_string(),
142        Value::Bytes(b) => format!(
143            "{TYPED}{BYTES}:{}",
144            base64::engine::general_purpose::STANDARD.encode(b)
145        ),
146        _ => return None,
147    })
148}
149
150/// `v` with its typed values as their strings (`@file:x`): plain values
151/// only, for a tool that knows no typed ones.
152pub fn flatten(v: &Value) -> Value {
153    match v {
154        Value::List(items) => Value::List(items.iter().map(flatten).collect()),
155        Value::Map(m) => Value::Map(m.iter().map(|(k, x)| (k.to_string(), flatten(x))).collect()),
156        other => spelling(other).map_or_else(|| other.clone(), Value::Str),
157    }
158}
159
160/// `s` with a leading `@` doubled, so it reads back as plain text.
161#[must_use]
162pub fn escape_at(s: &str) -> String {
163    if s.starts_with(TYPED) {
164        format!("{TYPED}{s}")
165    } else {
166        s.to_string()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn r(kind: &str, id: &str) -> Spelled {
175        Spelled::Ref(Ref::parse(kind, id).unwrap())
176    }
177
178    #[test]
179    fn reads_every_form() {
180        assert_eq!(classify("plain").unwrap(), Spelled::Plain("plain".into()));
181        assert_eq!(classify("@@me").unwrap(), Spelled::Plain("@me".into()));
182        assert_eq!(
183            classify("@file:images/logo.png").unwrap(),
184            r("file", "images/logo.png")
185        );
186        assert_eq!(
187            classify("@user:team:alice").unwrap(),
188            r("user", "team:alice")
189        );
190        assert_eq!(
191            classify("@bytes:AQID").unwrap(),
192            Spelled::Bytes(vec![1, 2, 3])
193        );
194    }
195
196    #[test]
197    fn rejects_typos() {
198        assert!(matches!(classify("@"), Err(TypedError::NoKind { .. })));
199        assert!(matches!(classify("@logo"), Err(TypedError::NoKind { .. })));
200        assert!(matches!(
201            classify("@File:logo"),
202            Err(TypedError::BadKind { .. })
203        ));
204        assert!(matches!(
205            classify("@file:"),
206            Err(TypedError::BadId { kind: Some(_), .. })
207        ));
208        assert!(matches!(
209            classify("@file:a b"),
210            Err(TypedError::BadId { kind: Some(_), .. })
211        ));
212        assert!(matches!(
213            classify("@bytes:!!"),
214            Err(TypedError::BadBytes(_))
215        ));
216    }
217
218    #[test]
219    fn round_trips() {
220        for s in [
221            "@file:images/logo.png",
222            "@doc:guides/intro[0]",
223            "@bytes:AQID",
224        ] {
225            assert_eq!(spelling(&classify(s).unwrap().into_value()).unwrap(), s);
226        }
227        assert_eq!(
228            classify("@@at").unwrap().into_value(),
229            Value::Str("@@at".into())
230        );
231    }
232}