1use serde::{Deserialize, Deserializer};
17
18use crate::nsid_syntax;
19
20#[derive(Debug, Clone, Deserialize)]
22pub struct LexiconDoc {
23 pub lexicon: u32,
25 #[serde(deserialize_with = "nsid_id")]
32 pub id: String,
33 #[serde(default)]
35 pub description: Option<String>,
36 pub defs: serde_json::Map<String, serde_json::Value>,
38}
39
40fn nsid_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
42 let id = String::deserialize(deserializer)?;
43 nsid_syntax::validate(&id).map_err(serde::de::Error::custom)?;
44 Ok(id)
45}
46
47impl LexiconDoc {
48 pub fn main(&self) -> Option<&serde_json::Value> {
50 self.defs.get("main")
51 }
52
53 pub fn main_type(&self) -> Option<&str> {
55 self.main()?.get("type")?.as_str()
56 }
57
58 pub fn record_key(&self) -> Option<&str> {
60 self.main()?.get("key")?.as_str()
61 }
62}
63
64#[derive(Debug, thiserror::Error)]
66pub enum LexiconError {
67 #[error("lexicon {path} did not parse: {source}")]
69 Parse {
70 path: &'static str,
72 #[source]
74 source: serde_json::Error,
75 },
76}
77
78#[derive(Debug, Clone, Copy)]
80pub struct Embedded {
81 pub path: &'static str,
83 pub source: &'static str,
85}
86
87impl Embedded {
88 pub fn parse(&self) -> Result<LexiconDoc, LexiconError> {
90 serde_json::from_str(self.source).map_err(|source| LexiconError::Parse {
91 path: self.path,
92 source,
93 })
94 }
95}
96
97macro_rules! embed {
98 ($($path:literal),* $(,)?) => {
99 pub const EMBEDDED: &[Embedded] = &[
101 $(Embedded { path: concat!("lexicons/", $path), source: include_str!(concat!("../../../lexicons/", $path)) }),*
102 ];
103 };
104}
105
106embed! {
107 "bot/did/registration.json",
108 "bot/did/operator.json",
109 "bot/did/credential.json",
110 "bot/did/policy.json",
111 "bot/did/policyBinding.json",
112 "bot/did/activateAccount.json",
113 "bot/did/approveAuthorization.json",
114 "bot/did/createAccount.json",
115 "bot/did/createBrowserSignIn.json",
116 "bot/did/createSession.json",
117 "bot/did/deactivateAccount.json",
118 "bot/did/declineAuthorization.json",
119 "bot/did/deleteAccount.json",
120 "bot/did/describeParkedKey.json",
121 "bot/did/freezeAccount.json",
122 "bot/did/getAccountLedger.json",
123 "bot/did/getAuthorization.json",
124 "bot/did/listAccountLedgers.json",
125 "bot/did/listAccounts.json",
126 "bot/did/listPendingAuthorizations.json",
127 "bot/did/parkKey.json",
128 "bot/did/pollOperatorClaim.json",
129 "bot/did/stats.json",
130 "bot/did/unfreezeAccount.json",
131 "bot/did/updateCredentials.json",
132}
133
134pub fn load_all() -> Result<Vec<LexiconDoc>, LexiconError> {
141 EMBEDDED.iter().map(Embedded::parse).collect()
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::nsid;
148
149 fn docs() -> Vec<LexiconDoc> {
150 load_all().expect("every embedded lexicon parses")
151 }
152
153 #[test]
154 fn every_document_declares_lexicon_version_one() {
155 for doc in docs() {
156 assert_eq!(doc.lexicon, 1, "{} declares an unexpected version", doc.id);
157 }
158 }
159
160 #[test]
161 fn document_id_matches_its_path() {
162 for embedded in EMBEDDED {
166 let doc = embedded.parse().expect("parses");
167 let expected = embedded
168 .path
169 .trim_start_matches("lexicons/")
170 .trim_end_matches(".json")
171 .replace('/', ".");
172 assert_eq!(doc.id, expected, "id/path mismatch at {}", embedded.path);
173 }
174 }
175
176 #[test]
177 fn every_document_id_is_a_known_nsid() {
178 let mut ids: Vec<String> = docs().into_iter().map(|d| d.id).collect();
179 ids.sort();
180 let mut known: Vec<String> = nsid::ALL
181 .iter()
182 .chain(nsid::METHODS)
183 .map(|s| s.to_string())
184 .collect();
185 known.sort();
186 assert_eq!(
187 ids, known,
188 "lexicons/ and nsid::ALL + nsid::METHODS disagree"
189 );
190 }
191
192 #[test]
193 fn every_document_has_a_main_definition() {
194 for doc in docs() {
195 assert!(doc.main().is_some(), "{} has no main def", doc.id);
196 }
197 }
198
199 #[test]
200 fn every_primary_def_is_a_record_a_query_or_a_procedure() {
201 for doc in docs() {
202 let kind = doc
203 .main_type()
204 .unwrap_or_else(|| panic!("{} has no primary type", doc.id));
205 assert!(
206 matches!(kind, "record" | "query" | "procedure"),
207 "{} is a {kind}, which this project does not define",
208 doc.id
209 );
210 }
211 }
212
213 #[test]
214 fn every_record_declares_a_known_key_strategy() {
215 for doc in docs()
216 .iter()
217 .filter(|doc| doc.main_type() == Some("record"))
218 {
219 let key = doc
220 .record_key()
221 .unwrap_or_else(|| panic!("{} has no key", doc.id));
222 assert!(
223 key == "tid" || key == "any" || key.starts_with("literal:"),
224 "{} uses an unrecognized record key strategy: {key}",
225 doc.id
226 );
227 }
228 }
229
230 #[test]
236 fn every_method_document_is_one_of_the_method_nsids() {
237 let mut ids: Vec<String> = docs()
238 .iter()
239 .filter(|doc| matches!(doc.main_type(), Some("query" | "procedure")))
240 .map(|doc| doc.id.clone())
241 .collect();
242 ids.sort();
243 let mut known: Vec<String> = nsid::METHODS.iter().map(|s| s.to_string()).collect();
244 known.sort();
245 assert_eq!(ids, known);
246 }
247
248 fn schemas_of(def: &serde_json::Value) -> Vec<&serde_json::Value> {
252 let body = |name: &str| def.get(name).and_then(|body| body.get("schema"));
253 [
254 def.get("record"),
255 def.get("parameters"),
256 body("input"),
257 body("output"),
258 ]
259 .into_iter()
260 .flatten()
261 .chain(def.get("properties").map(|_| def))
262 .collect()
263 }
264
265 fn describes_every_property(id: &str, def: &str, schema: &serde_json::Value) {
268 let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
269 return;
270 };
271 for (field, property) in properties {
272 assert!(
273 property
274 .get("description")
275 .and_then(|d| d.as_str())
276 .is_some(),
277 "{id}#{def}.{field} has no description"
278 );
279 if let Some(items) = property.get("items") {
280 describes_every_property(id, def, items);
281 }
282 describes_every_property(id, def, property);
283 }
284 }
285
286 #[test]
287 fn every_definition_and_every_field_is_described() {
288 for doc in docs() {
293 for (name, def) in &doc.defs {
294 assert!(
295 def.get("description").and_then(|d| d.as_str()).is_some(),
296 "{}#{name} has no description",
297 doc.id
298 );
299 for schema in schemas_of(def) {
300 describes_every_property(&doc.id, name, schema);
301 }
302 }
303 }
304 }
305
306 #[test]
307 fn the_singleton_records_use_literal_self() {
308 for doc in docs() {
311 if doc.id == nsid::REGISTRATION {
312 assert_eq!(doc.record_key(), Some("literal:self"), "{}", doc.id);
313 }
314 }
315 }
316}