substrate_data/model/
typed.rs1use std::fmt;
13
14use base64::Engine as _;
15
16use crate::model::value::{KindName, Ref, Value, ValueKind};
17use crate::names::TYPED;
18
19pub(crate) const BYTES: &str = "bytes";
22
23#[derive(Debug, Clone, PartialEq)]
25pub enum Spelled {
26 Plain(String),
28 Ref(Ref),
30 Bytes(Vec<u8>),
32}
33
34impl Spelled {
35 #[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 #[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#[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 NoKind {
65 spelled: String,
67 },
68 BadKind {
70 kind: String,
72 },
73 BadId {
75 kind: Option<KindName>,
77 id: String,
79 },
80 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
113pub 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
138pub 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
150pub 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#[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}