1use crate::CompileError;
7
8const UNREPRESENTABLE: &[(&str, &str)] = &[
11 ("patternProperties", "declare explicit 'properties' instead"),
12 (
13 "prefixItems",
14 "declare a named object; tuples do not round-trip to SQL",
15 ),
16 (
17 "$ref",
18 "inline the definition; cross-schema refs land in a later phase",
19 ),
20 ("allOf", "flatten the composition into one object"),
21 ("anyOf", "split into separate contracts"),
22 ("oneOf", "split into separate contracts"),
23 ("not", "express the constraint positively"),
24];
25
26const SUPPORTED_FORMATS: &[&str] = &["email"];
27
28const TOP_LEVEL_KEYWORDS: &[&str] = &[
35 "$schema",
36 "$comment",
37 "type",
38 "properties",
39 "required",
40 "additionalProperties",
41];
42
43const PROPERTY_KEYWORDS: &[&str] = &[
45 "type",
46 "minLength",
47 "maxLength",
48 "format",
49 "enum",
50 "default",
51 "pattern",
52];
53
54const AUTHORING_EMAIL_PATTERN: &str = r"^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$";
64
65#[derive(Debug, Clone, PartialEq)]
66pub enum PropertyKind {
67 String {
68 min_length: Option<u64>,
69 max_length: Option<u64>,
70 format: Option<String>,
71 enum_values: Option<Vec<String>>,
72 default: Option<String>,
73 },
74}
75
76#[derive(Debug, Clone, PartialEq)]
77pub struct Property {
78 pub name: String,
79 pub kind: PropertyKind,
80 pub required: bool,
81}
82
83#[derive(Debug, Clone)]
84pub struct ContractSchema {
85 pub contract_name: String,
86 pub properties: Vec<Property>,
87}
88
89pub fn parse(contract_name: &str, schema_json: &str) -> Result<ContractSchema, CompileError> {
90 let value: serde_json::Value =
91 serde_json::from_str(schema_json).map_err(|error| CompileError::InvalidSchema {
92 message: error.to_string(),
93 })?;
94
95 require_identifier("contract name", contract_name)?;
96 reject_unrepresentable(&value)?;
97 reject_unknown_top_level_keywords(&value)?;
98 require_strict_object(&value)?;
99
100 let required = required_names(&value)?;
101 let properties = parse_properties(&value, &required)?;
102
103 Ok(ContractSchema {
104 contract_name: contract_name.to_owned(),
105 properties,
106 })
107}
108
109fn reject_unrepresentable(value: &serde_json::Value) -> Result<(), CompileError> {
114 match value {
115 serde_json::Value::Object(map) => {
116 for (key, child) in map {
117 if let Some((construct, alternative)) = UNREPRESENTABLE
118 .iter()
119 .find(|(construct, _)| *construct == key)
120 {
121 return Err(CompileError::Unrepresentable {
122 construct: (*construct).to_owned(),
123 alternatives: (*alternative).to_owned(),
124 });
125 }
126 if key == "properties" {
127 if let Some(properties) = child.as_object() {
128 for subschema in properties.values() {
129 reject_unrepresentable(subschema)?;
130 }
131 continue;
132 }
133 }
134 reject_unrepresentable(child)?;
135 }
136 }
137 serde_json::Value::Array(entries) => {
138 for entry in entries {
139 reject_unrepresentable(entry)?;
140 }
141 }
142 _ => {}
143 }
144 Ok(())
145}
146
147fn reject_unknown_top_level_keywords(value: &serde_json::Value) -> Result<(), CompileError> {
155 for key in value.as_object().into_iter().flatten().map(|(key, _)| key) {
156 if !TOP_LEVEL_KEYWORDS.contains(&key.as_str()) {
157 return Err(CompileError::Unrepresentable {
158 construct: format!("top-level keyword '{key}'"),
159 alternatives: format!(
160 "the subset carries {}; annotation keywords are not \
161 emitted to any target, so carrying them would drift \
162 the bindings — remove it, or propose it as a \
163 widening step",
164 TOP_LEVEL_KEYWORDS.join(", ")
165 ),
166 });
167 }
168 }
169 Ok(())
170}
171
172fn reject_unknown_property_keywords(
178 name: &str,
179 spec: &serde_json::Value,
180) -> Result<(), CompileError> {
181 for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
182 if !PROPERTY_KEYWORDS.contains(&key.as_str()) {
183 return Err(CompileError::Unrepresentable {
184 construct: format!("keyword '{key}' on property '{name}'"),
185 alternatives: format!(
186 "the subset carries {}; remove it, or propose it as a \
187 widening step",
188 PROPERTY_KEYWORDS.join(", ")
189 ),
190 });
191 }
192 }
193 Ok(())
194}
195
196fn validate_pattern(
201 name: &str,
202 spec: &serde_json::Value,
203 format: Option<&str>,
204) -> Result<(), CompileError> {
205 let Some(value) = spec.get("pattern") else {
206 return Ok(());
207 };
208 let Some(pattern) = value.as_str() else {
209 return Err(invalid_property_keyword(name, "'pattern' must be a string"));
210 };
211 if format == Some("email") && pattern == AUTHORING_EMAIL_PATTERN {
212 return Ok(());
213 }
214 Err(CompileError::Unrepresentable {
215 construct: format!("'pattern' on property '{name}'"),
216 alternatives: "format 'email', or propose pattern support as a widening step".to_owned(),
217 })
218}
219
220fn require_strict_object(value: &serde_json::Value) -> Result<(), CompileError> {
221 if value.get("type").and_then(serde_json::Value::as_str) != Some("object") {
222 return Err(CompileError::InvalidSchema {
223 message: "top-level schema must be an object type".to_owned(),
224 });
225 }
226 if value.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
227 return Err(CompileError::InvalidSchema {
228 message: "additionalProperties must be false (strictness is mandatory, charter N2)"
229 .to_owned(),
230 });
231 }
232 Ok(())
233}
234
235fn required_names(value: &serde_json::Value) -> Result<Vec<String>, CompileError> {
236 let Some(required) = value.get("required") else {
237 return Ok(Vec::new());
238 };
239 let Some(entries) = required.as_array() else {
240 return Err(invalid_keyword("required", "must be an array of strings"));
241 };
242 entries
243 .iter()
244 .map(|entry| {
245 entry
246 .as_str()
247 .map(str::to_owned)
248 .ok_or_else(|| invalid_keyword("required", "entries must all be strings"))
249 })
250 .collect()
251}
252
253fn parse_properties(
254 value: &serde_json::Value,
255 required: &[String],
256) -> Result<Vec<Property>, CompileError> {
257 let Some(map) = value
258 .get("properties")
259 .and_then(serde_json::Value::as_object)
260 else {
261 return Err(CompileError::InvalidSchema {
262 message: "schema declares no properties".to_owned(),
263 });
264 };
265 let mut properties = Vec::new();
266 for (name, spec) in map {
267 require_identifier("property name", name)?;
268 let kind = parse_string_property(name, spec)?;
269 let required = required.contains(name);
270 reject_required_with_default(name, &kind, required)?;
271 properties.push(Property {
272 name: name.clone(),
273 kind,
274 required,
275 });
276 }
277 Ok(properties)
278}
279
280fn reject_required_with_default(
285 name: &str,
286 kind: &PropertyKind,
287 required: bool,
288) -> Result<(), CompileError> {
289 let PropertyKind::String { default, .. } = kind;
290 if required && default.is_some() {
291 return Err(CompileError::InvalidSchema {
292 message: format!(
293 "property '{name}' is both required and has a default; \
294 choose one: required (caller must send it) or \
295 default (caller may omit it)"
296 ),
297 });
298 }
299 Ok(())
300}
301
302fn require_identifier(role: &str, name: &str) -> Result<(), CompileError> {
306 let mut chars = name.chars();
307 let valid = chars
308 .next()
309 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
310 && chars.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
311 if valid {
312 return Ok(());
313 }
314 Err(CompileError::InvalidSchema {
315 message: format!(
316 "{role} '{name}' is not a portable identifier; \
317 names must match [A-Za-z_][A-Za-z0-9_]* to emit into all targets"
318 ),
319 })
320}
321
322fn parse_string_property(
323 name: &str,
324 spec: &serde_json::Value,
325) -> Result<PropertyKind, CompileError> {
326 let type_name = spec.get("type").and_then(serde_json::Value::as_str);
327 if type_name != Some("string") {
328 return Err(CompileError::Unrepresentable {
329 construct: format!("property '{name}' of type {type_name:?}"),
330 alternatives: "Phase 1 subset carries string properties; widen in a later phase"
331 .to_owned(),
332 });
333 }
334 reject_unknown_property_keywords(name, spec)?;
335 let format = parse_format(name, spec)?;
336 validate_pattern(name, spec, format.as_deref())?;
337 let enum_values = parse_enum(name, spec)?;
338 let default = parse_default(name, spec)?;
339 if let (Some(values), Some(value)) = (&enum_values, &default) {
340 if !values.contains(value) {
341 return Err(invalid_property_keyword(
342 name,
343 "'default' must be one of the declared 'enum' values",
344 ));
345 }
346 }
347 Ok(PropertyKind::String {
348 min_length: spec.get("minLength").and_then(serde_json::Value::as_u64),
349 max_length: spec.get("maxLength").and_then(serde_json::Value::as_u64),
350 format,
351 enum_values,
352 default,
353 })
354}
355
356fn parse_format(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
357 let Some(value) = spec.get("format") else {
358 return Ok(None);
359 };
360 let Some(format) = value.as_str() else {
361 return Err(invalid_keyword("format", "must be a string"));
362 };
363 if !SUPPORTED_FORMATS.contains(&format) {
364 return Err(CompileError::Unrepresentable {
365 construct: format!("format '{format}' on property '{name}'"),
366 alternatives: "format 'email', or omit 'format'".to_owned(),
367 });
368 }
369 Ok(Some(format.to_owned()))
370}
371
372fn parse_enum(name: &str, spec: &serde_json::Value) -> Result<Option<Vec<String>>, CompileError> {
373 let Some(value) = spec.get("enum") else {
374 return Ok(None);
375 };
376 let Some(entries) = value.as_array() else {
377 return Err(invalid_property_keyword(name, "'enum' must be an array"));
378 };
379 entries
380 .iter()
381 .map(|entry| {
382 entry
383 .as_str()
384 .map(str::to_owned)
385 .ok_or_else(|| invalid_property_keyword(name, "'enum' entries must all be strings"))
386 })
387 .collect::<Result<Vec<_>, _>>()
388 .map(Some)
389}
390
391fn parse_default(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
392 let Some(value) = spec.get("default") else {
393 return Ok(None);
394 };
395 value.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
396 invalid_property_keyword(name, "'default' must be a string for a string property")
397 })
398}
399
400fn invalid_keyword(keyword: &str, detail: &str) -> CompileError {
401 CompileError::InvalidSchema {
402 message: format!("'{keyword}' {detail}"),
403 }
404}
405
406fn invalid_property_keyword(name: &str, detail: &str) -> CompileError {
407 CompileError::InvalidSchema {
408 message: format!("property '{name}' keyword {detail}"),
409 }
410}