Skip to main content

pushkin_compiler/
schema.rs

1//! Parsing + constrained-subset validation of the canonical JSON Schema.
2//! The subset is deliberately small in Phase 1: strict objects with string
3//! properties (length/format/enum constraints). Everything else is rejected
4//! by name with alternatives.
5
6use crate::CompileError;
7
8/// Constructs the Phase 1 subset does not carry, with the alternative named
9/// in the rejection (spec §5.1 — never silently dropped).
10const 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
28/// Keywords the parser consumes at the top level. The gate is BIJECTIVE
29/// (F26): a keyword outside this set is rejected by name, never
30/// tolerated-and-ignored. Tolerating an unconsumed keyword was a silent
31/// DROP before the typify swap and, after it, silent cross-target DRIFT
32/// — `targets/rust.rs` feeds typify the RAW schema JSON, so a keyword
33/// the parser ignores still shapes the Rust binding alone.
34const TOP_LEVEL_KEYWORDS: &[&str] = &[
35    "$schema",
36    "$comment",
37    "type",
38    "properties",
39    "required",
40    "additionalProperties",
41];
42
43/// Keywords the parser consumes on a property subschema (F26).
44const PROPERTY_KEYWORDS: &[&str] = &[
45    "type",
46    "minLength",
47    "maxLength",
48    "format",
49    "enum",
50    "default",
51    "pattern",
52];
53
54/// The email regex the authoring pipeline (Zod v4 `z.email()`) emits
55/// alongside `format: "email"`. `pattern` is admitted ONLY as this
56/// format's companion and ONLY byte-equal to this constant (human
57/// decision, recorded §7).
58///
59/// A future authoring-library bump that changes the emitted regex will
60/// fail compile loudly here. That is the DESIGNED behavior, not a bug:
61/// the alternative is the silent cross-target drift this gate closes.
62/// Remediation is a deliberate constant update in the same PR as the bump.
63const 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
109/// Walks the schema keyword-aware: keys of a schema object are keywords and
110/// are matched against the unrepresentable list, but the keys of a
111/// `properties` map are user-defined property NAMES — data, not structure —
112/// so only their values (subschemas) are walked (S1).
113fn 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
147/// F26: the top-level counterpart to the property allowlist. Runs AFTER
148/// `reject_unrepresentable` so the named constructs keep their
149/// `Unrepresentable` variant and their specific alternatives.
150///
151/// `parse` has already parsed the document, and a non-object top level is
152/// rejected by `require_strict_object`; the empty-map case simply finds
153/// no unknown key.
154fn 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
172/// F26: a property keyword the parser does not consume would reach
173/// typify through the raw-JSON feed and shape the Rust binding ONLY.
174///
175/// Called from `parse_string_property` after its `type` check, so `spec`
176/// is always an object here.
177fn 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
196/// F26(b), human decision: `pattern` is admitted ONLY as the email
197/// format's companion, byte-equal to what the authoring pipeline emits.
198/// Zod/Pydantic/SQL carry no pattern handling, so any other pattern
199/// would be enforced in Rust alone — the exact drift this closes.
200fn 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
280/// `required` means the caller MUST send the key; `default` means fill it
281/// when omitted — emitted together every target silently demotes the field
282/// to optional (S5 human decision, option a: loud rejection, never a quiet
283/// winner).
284fn 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
302/// Names are emitted verbatim into TS, Python, Rust, and SQL identifiers
303/// (S2): only `^[A-Za-z_][A-Za-z0-9_]*$` is portable across all four
304/// without renaming machinery, which is out of scope by decision.
305fn 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}