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 SCALAR_KEYWORDS: &[&str] = &["type", "default"];
60
61const 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,}$";
71
72#[derive(Debug, Clone, PartialEq)]
73pub enum PropertyKind {
74 String {
75 min_length: Option<u64>,
76 max_length: Option<u64>,
77 format: Option<String>,
78 enum_values: Option<Vec<String>>,
79 default: Option<String>,
80 },
81 Integer {
82 default: Option<i64>,
83 },
84 Number {
85 default: Option<f64>,
86 },
87 Boolean {
88 default: Option<bool>,
89 },
90}
91
92impl PropertyKind {
93 #[must_use]
95 pub fn has_default(&self) -> bool {
96 match self {
97 Self::String { default, .. } => default.is_some(),
98 Self::Integer { default } => default.is_some(),
99 Self::Number { default } => default.is_some(),
100 Self::Boolean { default } => default.is_some(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub struct Property {
107 pub name: String,
108 pub kind: PropertyKind,
109 pub required: bool,
110}
111
112#[derive(Debug, Clone)]
113pub struct ContractSchema {
114 pub contract_name: String,
115 pub properties: Vec<Property>,
116}
117
118pub fn parse(contract_name: &str, schema_json: &str) -> Result<ContractSchema, CompileError> {
119 let value: serde_json::Value =
120 serde_json::from_str(schema_json).map_err(|error| CompileError::InvalidSchema {
121 message: error.to_string(),
122 })?;
123
124 require_identifier("contract name", contract_name)?;
125 reject_unrepresentable(&value)?;
126 reject_unknown_top_level_keywords(&value)?;
127 require_strict_object(&value)?;
128
129 let required = required_names(&value)?;
130 let properties = parse_properties(&value, &required)?;
131
132 Ok(ContractSchema {
133 contract_name: contract_name.to_owned(),
134 properties,
135 })
136}
137
138fn reject_unrepresentable(value: &serde_json::Value) -> Result<(), CompileError> {
143 match value {
144 serde_json::Value::Object(map) => {
145 for (key, child) in map {
146 if let Some((construct, alternative)) = UNREPRESENTABLE
147 .iter()
148 .find(|(construct, _)| *construct == key)
149 {
150 return Err(CompileError::Unrepresentable {
151 construct: (*construct).to_owned(),
152 alternatives: (*alternative).to_owned(),
153 });
154 }
155 if key == "properties" {
156 if let Some(properties) = child.as_object() {
157 for subschema in properties.values() {
158 reject_unrepresentable(subschema)?;
159 }
160 continue;
161 }
162 }
163 reject_unrepresentable(child)?;
164 }
165 }
166 serde_json::Value::Array(entries) => {
167 for entry in entries {
168 reject_unrepresentable(entry)?;
169 }
170 }
171 _ => {}
172 }
173 Ok(())
174}
175
176fn reject_unknown_top_level_keywords(value: &serde_json::Value) -> Result<(), CompileError> {
184 for key in value.as_object().into_iter().flatten().map(|(key, _)| key) {
185 if !TOP_LEVEL_KEYWORDS.contains(&key.as_str()) {
186 return Err(CompileError::Unrepresentable {
187 construct: format!("top-level keyword '{key}'"),
188 alternatives: format!(
189 "the subset carries {}; annotation keywords are not \
190 emitted to any target, so carrying them would drift \
191 the bindings — remove it, or propose it as a \
192 widening step",
193 TOP_LEVEL_KEYWORDS.join(", ")
194 ),
195 });
196 }
197 }
198 Ok(())
199}
200
201fn reject_unknown_property_keywords(
207 name: &str,
208 spec: &serde_json::Value,
209) -> Result<(), CompileError> {
210 for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
211 if !PROPERTY_KEYWORDS.contains(&key.as_str()) {
212 return Err(CompileError::Unrepresentable {
213 construct: format!("keyword '{key}' on property '{name}'"),
214 alternatives: format!(
215 "the subset carries {}; remove it, or propose it as a \
216 widening step",
217 PROPERTY_KEYWORDS.join(", ")
218 ),
219 });
220 }
221 }
222 Ok(())
223}
224
225fn validate_pattern(
230 name: &str,
231 spec: &serde_json::Value,
232 format: Option<&str>,
233) -> Result<(), CompileError> {
234 let Some(value) = spec.get("pattern") else {
235 return Ok(());
236 };
237 let Some(pattern) = value.as_str() else {
238 return Err(invalid_property_keyword(name, "'pattern' must be a string"));
239 };
240 if format == Some("email") && pattern == AUTHORING_EMAIL_PATTERN {
241 return Ok(());
242 }
243 Err(CompileError::Unrepresentable {
244 construct: format!("'pattern' on property '{name}'"),
245 alternatives: "format 'email', or propose pattern support as a widening step".to_owned(),
246 })
247}
248
249fn require_strict_object(value: &serde_json::Value) -> Result<(), CompileError> {
250 if value.get("type").and_then(serde_json::Value::as_str) != Some("object") {
251 return Err(CompileError::InvalidSchema {
252 message: "top-level schema must be an object type".to_owned(),
253 });
254 }
255 if value.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
256 return Err(CompileError::InvalidSchema {
257 message: "additionalProperties must be false (strictness is mandatory, charter N2)"
258 .to_owned(),
259 });
260 }
261 Ok(())
262}
263
264fn required_names(value: &serde_json::Value) -> Result<Vec<String>, CompileError> {
265 let Some(required) = value.get("required") else {
266 return Ok(Vec::new());
267 };
268 let Some(entries) = required.as_array() else {
269 return Err(invalid_keyword("required", "must be an array of strings"));
270 };
271 entries
272 .iter()
273 .map(|entry| {
274 entry
275 .as_str()
276 .map(str::to_owned)
277 .ok_or_else(|| invalid_keyword("required", "entries must all be strings"))
278 })
279 .collect()
280}
281
282fn parse_properties(
283 value: &serde_json::Value,
284 required: &[String],
285) -> Result<Vec<Property>, CompileError> {
286 let Some(map) = value
287 .get("properties")
288 .and_then(serde_json::Value::as_object)
289 else {
290 return Err(CompileError::InvalidSchema {
291 message: "schema declares no properties".to_owned(),
292 });
293 };
294 let mut properties = Vec::new();
295 for (name, spec) in map {
296 require_identifier("property name", name)?;
297 let kind = parse_property(name, spec)?;
298 let required = required.contains(name);
299 reject_required_with_default(name, &kind, required)?;
300 properties.push(Property {
301 name: name.clone(),
302 kind,
303 required,
304 });
305 }
306 Ok(properties)
307}
308
309fn reject_required_with_default(
314 name: &str,
315 kind: &PropertyKind,
316 required: bool,
317) -> Result<(), CompileError> {
318 if required && kind.has_default() {
319 return Err(CompileError::InvalidSchema {
320 message: format!(
321 "property '{name}' is both required and has a default; \
322 choose one: required (caller must send it) or \
323 default (caller may omit it)"
324 ),
325 });
326 }
327 Ok(())
328}
329
330fn require_identifier(role: &str, name: &str) -> Result<(), CompileError> {
334 let mut chars = name.chars();
335 let valid = chars
336 .next()
337 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
338 && chars.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
339 if valid {
340 return Ok(());
341 }
342 Err(CompileError::InvalidSchema {
343 message: format!(
344 "{role} '{name}' is not a portable identifier; \
345 names must match [A-Za-z_][A-Za-z0-9_]* to emit into all targets"
346 ),
347 })
348}
349
350fn parse_property(name: &str, spec: &serde_json::Value) -> Result<PropertyKind, CompileError> {
351 match spec.get("type").and_then(serde_json::Value::as_str) {
352 Some("string") => parse_string_property(name, spec),
353 Some(scalar @ ("integer" | "number" | "boolean")) => {
354 parse_scalar_property(name, spec, scalar)
355 }
356 other => Err(CompileError::Unrepresentable {
357 construct: format!("property '{name}' of type {other:?}"),
358 alternatives: "the subset carries string, integer, number, and boolean \
359 properties; arrays and objects land in a later widening step"
360 .to_owned(),
361 }),
362 }
363}
364
365fn parse_scalar_property(
368 name: &str,
369 spec: &serde_json::Value,
370 scalar: &str,
371) -> Result<PropertyKind, CompileError> {
372 for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
373 if !SCALAR_KEYWORDS.contains(&key.as_str()) {
374 return Err(CompileError::Unrepresentable {
375 construct: format!("keyword '{key}' on {scalar} property '{name}'"),
376 alternatives: format!(
377 "the scalar widening step carries {}; remove it, or \
378 propose it as a widening step",
379 SCALAR_KEYWORDS.join(", ")
380 ),
381 });
382 }
383 }
384 let default = spec.get("default");
385 match scalar {
386 "integer" => {
387 let parsed = typed_default(name, default, scalar, serde_json::Value::as_i64)?;
388 if let Some(val) = parsed {
389 let max_safe = 9_007_199_254_740_991_i64;
395 let min_safe = -9_007_199_254_740_991_i64;
396 if val > max_safe || val < min_safe {
397 return Err(CompileError::InvalidSchema {
398 message: format!(
399 "integer default {val} on property '{name}' exceeds the \
400 safe range for JavaScript number binding (±2^53-1); \
401 the Zod target would emit a different value"
402 ),
403 });
404 }
405 }
406 Ok(PropertyKind::Integer { default: parsed })
407 }
408 "number" => Ok(PropertyKind::Number {
409 default: typed_default(name, default, scalar, serde_json::Value::as_f64)?,
410 }),
411 _ => Ok(PropertyKind::Boolean {
412 default: typed_default(name, default, scalar, serde_json::Value::as_bool)?,
413 }),
414 }
415}
416
417fn typed_default<T>(
418 name: &str,
419 value: Option<&serde_json::Value>,
420 scalar: &str,
421 extract: impl Fn(&serde_json::Value) -> Option<T>,
422) -> Result<Option<T>, CompileError> {
423 let Some(value) = value else {
424 return Ok(None);
425 };
426 extract(value).map(Some).ok_or_else(|| {
427 invalid_property_keyword(
428 name,
429 &format!("'default' must be a {scalar} for a {scalar} property"),
430 )
431 })
432}
433
434fn parse_string_property(
435 name: &str,
436 spec: &serde_json::Value,
437) -> Result<PropertyKind, CompileError> {
438 let type_name = spec.get("type").and_then(serde_json::Value::as_str);
439 if type_name != Some("string") {
440 return Err(CompileError::Unrepresentable {
441 construct: format!("property '{name}' of type {type_name:?}"),
442 alternatives: "Phase 1 subset carries string properties; widen in a later phase"
443 .to_owned(),
444 });
445 }
446 reject_unknown_property_keywords(name, spec)?;
447 let format = parse_format(name, spec)?;
448 validate_pattern(name, spec, format.as_deref())?;
449 let enum_values = parse_enum(name, spec)?;
450 let default = parse_default(name, spec)?;
451 if let (Some(values), Some(value)) = (&enum_values, &default) {
452 if !values.contains(value) {
453 return Err(invalid_property_keyword(
454 name,
455 "'default' must be one of the declared 'enum' values",
456 ));
457 }
458 }
459 Ok(PropertyKind::String {
460 min_length: spec.get("minLength").and_then(serde_json::Value::as_u64),
461 max_length: spec.get("maxLength").and_then(serde_json::Value::as_u64),
462 format,
463 enum_values,
464 default,
465 })
466}
467
468fn parse_format(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
469 let Some(value) = spec.get("format") else {
470 return Ok(None);
471 };
472 let Some(format) = value.as_str() else {
473 return Err(invalid_keyword("format", "must be a string"));
474 };
475 if !SUPPORTED_FORMATS.contains(&format) {
476 return Err(CompileError::Unrepresentable {
477 construct: format!("format '{format}' on property '{name}'"),
478 alternatives: "format 'email', or omit 'format'".to_owned(),
479 });
480 }
481 Ok(Some(format.to_owned()))
482}
483
484fn parse_enum(name: &str, spec: &serde_json::Value) -> Result<Option<Vec<String>>, CompileError> {
485 let Some(value) = spec.get("enum") else {
486 return Ok(None);
487 };
488 let Some(entries) = value.as_array() else {
489 return Err(invalid_property_keyword(name, "'enum' must be an array"));
490 };
491 entries
492 .iter()
493 .map(|entry| {
494 entry
495 .as_str()
496 .map(str::to_owned)
497 .ok_or_else(|| invalid_property_keyword(name, "'enum' entries must all be strings"))
498 })
499 .collect::<Result<Vec<_>, _>>()
500 .map(Some)
501}
502
503fn parse_default(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
504 let Some(value) = spec.get("default") else {
505 return Ok(None);
506 };
507 value.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
508 invalid_property_keyword(name, "'default' must be a string for a string property")
509 })
510}
511
512fn invalid_keyword(keyword: &str, detail: &str) -> CompileError {
513 CompileError::InvalidSchema {
514 message: format!("'{keyword}' {detail}"),
515 }
516}
517
518fn invalid_property_keyword(name: &str, detail: &str) -> CompileError {
519 CompileError::InvalidSchema {
520 message: format!("property '{name}' keyword {detail}"),
521 }
522}