1use crate::catalog::{CORE, CORE_CATALOG_ID};
6use crate::types::ExtensionDef;
7
8fn is_catalog_id(id: &str) -> bool {
10 id.strip_prefix("https://")
11 .or_else(|| id.strip_prefix("http://"))
12 .is_some_and(|rest| !rest.is_empty() && !rest.chars().any(char::is_whitespace))
13}
14
15fn is_pascal_name(name: &str) -> bool {
17 let mut chars = name.chars();
18 chars.next().is_some_and(|c| c.is_ascii_uppercase()) && chars.all(|c| c.is_ascii_alphanumeric())
19}
20
21pub fn validate_extension(def: &ExtensionDef) -> Vec<String> {
23 let core = &*CORE;
24 let mut errors = Vec::new();
25 if !is_catalog_id(&def.id) {
26 errors.push("id must be a URL-shaped catalog id".to_string());
27 }
28 if def.extends != CORE_CATALOG_ID {
29 errors.push(format!("extends must be {CORE_CATALOG_ID}"));
30 }
31 if def.name.is_empty() {
32 errors.push("name is required".to_string());
33 }
34 if def.components.is_empty() {
35 errors.push("an extension defines at least one component".to_string());
36 }
37 let ext_enums = def.enums.as_ref();
38 let ext_defs = def.defs.as_ref();
39 for (name, component) in &def.components {
40 if !is_pascal_name(name) {
41 errors.push(format!("{name}: component names are PascalCase"));
42 }
43 if core.components.contains_key(name) {
44 errors.push(format!("{name}: shadows a core component"));
45 }
46 if component.description.is_empty() {
47 errors.push(format!("{name}: description is required"));
48 }
49 for (prop, schema) in &component.props {
50 if schema.description.is_empty() {
51 errors.push(format!("{name}.{prop}: description is required"));
52 }
53 if let Some(e) = schema.enum_.as_deref().filter(|e| !e.is_empty()) {
54 if schema.type_ == "enum" && !core.enums.contains_key(e) && !ext_enums.is_some_and(|m| m.contains_key(e)) {
55 errors.push(format!("{name}.{prop}: enum {e} is not defined"));
56 }
57 }
58 if let Some(s) = schema.shape.as_deref().filter(|s| !s.is_empty()) {
59 if schema.type_ == "object" && !core.defs.contains_key(s) && !ext_defs.is_some_and(|m| m.contains_key(s)) {
60 errors.push(format!("{name}.{prop}: shape {s} is not defined"));
61 }
62 }
63 }
64 if component.is_macro() && !def.macros.as_ref().is_some_and(|m| m.contains_key(name)) {
65 errors.push(format!("{name}: a macro needs a template in macros"));
66 }
67 }
68 for name in def.macros.iter().flat_map(|m| m.keys()) {
69 if !def.components.get(name).is_some_and(|c| c.is_macro()) {
70 errors.push(format!("macros.{name}: no macro component of that name"));
71 }
72 }
73 for name in def.enums.iter().flat_map(|m| m.keys()) {
74 if core.enums.contains_key(name) {
75 errors.push(format!("enums.{name}: shadows a core enum"));
76 }
77 }
78 errors
79}
80
81pub fn define_extension(def: ExtensionDef) -> Result<ExtensionDef, String> {
83 let errors = validate_extension(&def);
84 if errors.is_empty() {
85 Ok(def)
86 } else {
87 Err(format!("extension {}:\n{}", def.id, errors.join("\n")))
88 }
89}
90
91pub fn parse_extension(json: &str) -> Result<ExtensionDef, String> {
93 let def: ExtensionDef = serde_json::from_str(json).map_err(|e| format!("extension: {e}"))?;
94 define_extension(def)
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn example() -> ExtensionDef {
102 let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../packages/exponential-ui/fixtures/catalog-extension.json"))
103 .expect("catalog-extension.json");
104 let file: serde_json::Value = serde_json::from_str(&text).unwrap();
105 serde_json::from_value(file["extension"].clone()).unwrap()
106 }
107
108 #[test]
109 fn define_extension_accepts_the_example_extension() {
110 let ex = example();
111 assert_eq!(validate_extension(&ex), Vec::<String>::new());
112 assert_eq!(define_extension(ex.clone()), Ok(ex.clone()));
113 let json = serde_json::to_string(&ex).unwrap();
114 assert_eq!(parse_extension(&json), Ok(ex));
115 }
116
117 #[test]
118 fn define_extension_refuses_a_core_name_a_missing_macro_template_a_wrong_base_and_a_bad_id() {
119 let ex = example();
120 let mut shadow = ex.clone();
121 shadow.components.insert("Button".into(), ex.components["TrendLine"].clone());
122 assert!(validate_extension(&shadow).contains(&"Button: shadows a core component".to_string()));
123 let mut no_template = ex.clone();
124 no_template.macros = Some(Default::default());
125 assert!(validate_extension(&no_template).contains(&"StatCard: a macro needs a template in macros".to_string()));
126 let mut wrong_base = ex.clone();
127 wrong_base.extends = "https://example.com/other".into();
128 assert!(validate_extension(&wrong_base).contains(&format!("extends must be {CORE_CATALOG_ID}")));
129 let mut bad_id = ex.clone();
130 bad_id.id = "example".into();
131 assert!(validate_extension(&bad_id).contains(&"id must be a URL-shaped catalog id".to_string()));
132 assert!(define_extension(bad_id).unwrap_err().contains("catalog id"));
133 assert!(parse_extension("{").is_err());
134 }
135}