pushkin-compiler 0.2.0

Schema compiler for pushkin: canonical JSON Schema emission and generated bindings
Documentation
//! R6 widening step 3: arrays of supported scalars.
//!
//! Admitted shape (D1): `{"type": "array", "items": {"type": <scalar>}}`
//! where `<scalar>` is one of `string | integer | number | boolean` and
//! `items` carries EXACTLY ONE keyword, `type`. `ARRAY_KEYWORDS` is
//! `["type", "items"]`.
//!
//! Everything else rejects by name (F26 bijectivity). The load-bearing
//! exclusion is CONSTRAINED `items`: Zod, Pydantic and Rust can each
//! express a constrained element type, but SQL cannot — a per-element
//! `enum` or length bound is not a column constraint, it needs a `CHECK`
//! over `unnest`. Admitting it would enforce the contract in three targets
//! and not the fourth, which is exactly the silent cross-target drift the
//! front gate exists to prevent.
//!
//! Cardinality keywords (`minItems`/`maxItems`/`uniqueItems`) reject for
//! the mirror-image reason: expressible in Zod, Pydantic and Postgres, but
//! typify has no cardinality constraint on `Vec<T>`, so Rust would silently
//! under-enforce.
//!
//! Array `default` is deferred to its own widening step (Python's mutable
//! default hazard, the SQL array-literal spelling, and empty-vs-populated
//! against S5 required+default parity are each their own design surface).
//!
//! Committed per the red-locally/commit-green protocol; read-only
//! hereafter (§4.1, N10).

use pushkin_compiler::{compile, CompileError, CompileRequest, Target};

const ALL_TARGETS: [Target; 4] = [Target::Zod, Target::Pydantic, Target::Rust, Target::Sql];

fn emit(schema: &str, target: Target) -> Result<String, CompileError> {
    compile(&CompileRequest {
        contract_name: "array_widening".to_owned(),
        schema_json: schema.to_owned(),
        target,
        epoch: 1,
    })
    .map(|output| output.content)
}

fn object_with(property_spec: &str) -> String {
    format!(
        r#"{{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {{
    "items_under_test": {property_spec}
  }},
  "required": ["items_under_test"],
  "additionalProperties": false
}}"#
    )
}

// ---------------------------------------------------------------------
// Acceptance: all four element types, all four targets.
// ---------------------------------------------------------------------

#[test]
fn arrays_of_each_supported_scalar_compile_on_every_target() {
    for element in ["string", "integer", "number", "boolean"] {
        let schema = object_with(&format!(
            r#"{{ "type": "array", "items": {{ "type": "{element}" }} }}"#
        ));
        for target in ALL_TARGETS {
            let out = emit(&schema, target);
            assert!(
                out.is_ok(),
                "array of {element} must compile on {target:?}: {out:?}"
            );
        }
    }
}

/// DECISION REVERSAL — charter Addendum A / D8 (2026-08-16).
///
/// This test previously asserted that a NON-REQUIRED array compiles on every
/// target. It was committed in that form at `2feb23e` under the
/// pre-amendment plan, and W3 execution then disproved the premise: typify
/// emits a bare `Vec<T>` for an optional array, byte-identical to the
/// required one.
///
/// ```text
/// REQUIRED  -> pub tags: ::std::vec::Vec<::std::string::String>,
/// OPTIONAL  -> pub tags: ::std::vec::Vec<::std::string::String>,
/// ```
///
/// The other three targets preserve the distinction — Zod
/// `z.array(z.string()).optional()`, Pydantic `Optional[list[str]] = None`,
/// SQL a nullable column — so an absent key and `[]` become
/// indistinguishable in exactly one of four targets. That is silent lossy
/// behavior, disqualifying by policy (R2) regardless of blast radius.
///
/// Every way to keep optional arrays alive works by shaping typify's feed or
/// post-processing its output: the second normalization path F26 explicitly
/// refused, for this exact reason. When a widening step cannot be made
/// drift-free across all four targets, narrow the step rather than widening
/// the machinery.
#[test]
fn non_required_array_rejects_on_every_target() {
    const SCHEMA: &str = r#"{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "tags": { "type": "array", "items": { "type": "string" } }
  },
  "additionalProperties": false
}"#;
    for target in ALL_TARGETS {
        let err = emit(SCHEMA, target).expect_err("a non-required array must reject");
        let message = err.to_string();
        assert!(
            message.contains("tags"),
            "{target:?} rejection must name the property: {message}"
        );
        assert!(
            message.contains("required"),
            "{target:?} rejection must state the requirement: {message}"
        );
        assert!(
            message.contains("own widening step"),
            "{target:?} rejection must name the alternative: {message}"
        );
    }
}

// ---------------------------------------------------------------------
// Rejections. Each must name the offending construct AND carry the
// allowlist in its alternatives (F26).
// ---------------------------------------------------------------------

/// Every rejection names the property, so the author can find it.
///
/// Returns the messages rather than asserting inside a plain fn: clippy's
/// `expect_used` denial is scoped to non-test fns (`clippy.toml`
/// `allow-expect-in-tests`), so the fallible call belongs in the `#[test]`
/// that owns it.
fn rejection_messages(property_spec: &str) -> Vec<(Target, Result<String, CompileError>)> {
    let schema = object_with(property_spec);
    ALL_TARGETS
        .into_iter()
        .map(|target| (target, emit(&schema, target)))
        .collect()
}

/// Assert every target rejected, naming the construct and the property.
macro_rules! assert_rejects_naming {
    ($property_spec:expr, $expected_fragment:expr $(,)?) => {
        for (target, outcome) in rejection_messages($property_spec) {
            let err = outcome.expect_err("construct outside the subset must reject");
            let message = err.to_string();
            assert!(
                message.contains($expected_fragment),
                "{target:?} rejection must name {:?}: {message}",
                $expected_fragment
            );
            assert!(
                message.contains("items_under_test"),
                "{target:?} rejection must name the property: {message}"
            );
        }
    };
}

#[test]
fn array_without_items_rejects() {
    // No element type means nothing to emit; there is no defensible default.
    assert_rejects_naming!(r#"{ "type": "array" }"#, "items");
}

#[test]
fn nested_array_rejects() {
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "array", "items": { "type": "string" } } }"#,
        "array",
    );
}

#[test]
fn array_of_objects_rejects() {
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "object" } }"#,
        "object",
    );
}

#[test]
fn array_items_with_format_rejects() {
    // SQL cannot express a per-element format; admitting it would enforce
    // the contract in three targets and not the fourth.
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "string", "format": "email" } }"#,
        "format",
    );
}

#[test]
fn array_items_with_max_length_rejects() {
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "string", "maxLength": 10 } }"#,
        "maxLength",
    );
}

#[test]
fn array_items_with_enum_rejects() {
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "string", "enum": ["a"] } }"#,
        "enum",
    );
}

#[test]
fn array_with_min_items_rejects() {
    // typify has no cardinality constraint on Vec<T>: Rust would silently
    // under-enforce what the other three targets check.
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "string" }, "minItems": 1 }"#,
        "minItems",
    );
}

#[test]
fn array_with_unique_items_rejects() {
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "string" }, "uniqueItems": true }"#,
        "uniqueItems",
    );
}

#[test]
fn array_with_default_rejects() {
    // Deferred to its own widening step, not silently tolerated.
    assert_rejects_naming!(
        r#"{ "type": "array", "items": { "type": "string" }, "default": [] }"#,
        "default",
    );
}

#[test]
fn array_rejection_carries_the_allowlist() {
    let schema =
        object_with(r#"{ "type": "array", "items": { "type": "string" }, "minItems": 1 }"#);
    let err = emit(&schema, Target::Zod).expect_err("minItems must reject");
    let message = err.to_string();
    assert!(
        message.contains("type") && message.contains("items"),
        "the rejection must state what IS admitted, not only what is not: {message}"
    );
}

// ---------------------------------------------------------------------
// Regression guard: step 2's scalars are untouched.
// ---------------------------------------------------------------------

#[test]
fn existing_scalar_widening_contract_still_compiles() {
    const WIDENING_SCHEMA: &str = r#"{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "age": { "type": "integer", "default": 30 },
    "ratio": { "type": "number", "default": 0.5 },
    "active": { "type": "boolean", "default": true },
    "score": { "type": "number" },
    "count": { "type": "integer" }
  },
  "required": ["score"],
  "additionalProperties": false
}"#;
    for target in ALL_TARGETS {
        let out = emit(WIDENING_SCHEMA, target);
        assert!(
            out.is_ok(),
            "{target:?} must still compile the scalar widening contract: {out:?}"
        );
    }
}