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