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
19const BYTES: &str = "bytes";
21
22#[derive(Debug, Clone, PartialEq)]
24pub enum Spelled {
25 Plain(String),
27 Ref(Ref),
29 Bytes(Vec<u8>),
31}
32
33impl Spelled {
34 #[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 #[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#[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 NoKind {
64 spelled: String,
66 },
67 BadKind {
69 kind: String,
71 },
72 BadId {
74 kind: Option<KindName>,
76 id: String,
78 },
79 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
112pub 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
137pub 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
149pub 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#[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}