Skip to main content

proofframe/contract/
ast.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Deserialize;
4use serde_json::{Map, Value};
5
6use crate::{ErrorCode, ProofFrameError};
7
8const DEFAULT_MAX_FINDINGS: usize = 100;
9const ROOT_FIELDS: &[&str] = &["columns", "max_findings", "version"];
10const RULE_FIELDS: &[&str] = &[
11    "allowed", "max", "min", "nan", "not_null", "pattern", "required", "unique",
12];
13
14/// Version of the serialized contract language.
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize)]
16pub enum ContractVersion {
17    /// Initial strict contract language used by ProofFrame 0.5.
18    #[serde(rename = "proofframe.contract.v1")]
19    V1,
20    /// Relational and dataset-level contract language introduced in ProofFrame 0.5.1.
21    #[serde(rename = "proofframe.contract.v2")]
22    V2,
23}
24
25/// Source-level floating-point NaN policy.
26#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum NaNPolicyAst {
29    /// Treat NaN as a validation failure when numeric rules inspect the value.
30    #[default]
31    Reject,
32    /// Permit NaN while applying numeric bounds only to ordered values.
33    Allow,
34}
35
36/// Exact source literal for a numeric, decimal, or timestamp bound.
37#[derive(Debug, Clone, PartialEq, Deserialize)]
38#[serde(untagged)]
39pub enum BoundAst {
40    /// A JSON number, preserved by `serde_json::Number` without conversion to `f64`.
41    Number(serde_json::Number),
42    /// A decimal string, signed timestamp ticks, or an offset-qualified ISO-8601 timestamp.
43    Text(String),
44}
45
46impl BoundAst {
47    /// Return the exact source spelling used for semantic type conversion.
48    #[must_use]
49    pub fn as_text(&self) -> &str {
50        match self {
51            Self::Number(number) => number.as_str(),
52            Self::Text(text) => text,
53        }
54    }
55}
56
57/// Syntax-level rule set for one named column.
58#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct RuleAst {
61    /// Require the column to exist.
62    #[serde(default)]
63    pub required: bool,
64    /// Reject null cells.
65    #[serde(default)]
66    pub not_null: bool,
67    /// Require exact uniqueness.
68    #[serde(default)]
69    pub unique: bool,
70    /// Inclusive lower bound.
71    pub min: Option<BoundAst>,
72    /// Inclusive upper bound.
73    pub max: Option<BoundAst>,
74    /// NaN handling for floating-point columns.
75    pub nan: Option<NaNPolicyAst>,
76    /// Regular expression applied to textual values.
77    pub pattern: Option<String>,
78    /// Exact textual allowlist.
79    pub allowed: Option<BTreeSet<String>>,
80}
81
82/// Strict, versioned syntax tree parsed from a ProofFrame contract document.
83#[derive(Debug, Clone, PartialEq, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct ContractAst {
86    /// Contract language version.
87    pub version: ContractVersion,
88    /// Column rules keyed by logical column name.
89    #[serde(default)]
90    pub columns: BTreeMap<String, RuleAst>,
91    /// Maximum number of row-level findings retained in memory.
92    #[serde(default = "default_max_findings")]
93    pub max_findings: usize,
94}
95
96impl ContractAst {
97    /// Parse a contract without silently accepting unknown fields.
98    pub fn from_json(source: &str) -> Result<Self, ProofFrameError> {
99        let value: Value = serde_json::from_str(source).map_err(|error| {
100            ProofFrameError::contract(
101                ErrorCode::ContractInvalidJson,
102                format!("Invalid contract JSON: {error}"),
103                None,
104            )
105        })?;
106
107        if value.get("status").is_some() {
108            return Err(ProofFrameError::contract(
109                ErrorCode::ContractUnknownField,
110                "The field `status` belongs to V2: set version to \"proofframe.contract.v2\"; omitted Python versions default to V1",
111                Some("$.version".to_string()),
112            ));
113        }
114        validate_known_fields(&value)?;
115        let contract: Self = serde_json::from_value(value).map_err(|error| {
116            ProofFrameError::contract(
117                ErrorCode::ContractInvalidJson,
118                format!("Invalid contract value: {error}"),
119                None,
120            )
121        })?;
122        if contract.version != ContractVersion::V1 {
123            return Err(ProofFrameError::contract(
124                ErrorCode::ContractInvalidJson,
125                "ContractAst accepts only proofframe.contract.v1; use ContractDocument for V2",
126                Some("$.version".to_string()),
127            ));
128        }
129        Ok(contract)
130    }
131}
132
133fn default_max_findings() -> usize {
134    DEFAULT_MAX_FINDINGS
135}
136
137fn validate_known_fields(value: &Value) -> Result<(), ProofFrameError> {
138    let root = value.as_object().ok_or_else(|| {
139        ProofFrameError::contract(
140            ErrorCode::ContractInvalidJson,
141            "A contract document must be a JSON object",
142            None,
143        )
144    })?;
145    reject_unknown(root, ROOT_FIELDS, "$".to_string())?;
146
147    let Some(columns) = root.get("columns") else {
148        return Ok(());
149    };
150    let columns = columns.as_object().ok_or_else(|| {
151        ProofFrameError::contract(
152            ErrorCode::ContractInvalidJson,
153            "Contract field `columns` must be a JSON object",
154            Some("$.columns".to_string()),
155        )
156    })?;
157
158    for (column, rule) in columns {
159        let column_path = append_path("$.columns", column);
160        let rule = rule.as_object().ok_or_else(|| {
161            ProofFrameError::contract(
162                ErrorCode::ContractInvalidJson,
163                format!("Rules for column `{column}` must be a JSON object"),
164                Some(column_path.clone()),
165            )
166        })?;
167        reject_unknown(rule, RULE_FIELDS, column_path)?;
168    }
169    Ok(())
170}
171
172fn reject_unknown(
173    object: &Map<String, Value>,
174    allowed: &[&str],
175    parent_path: String,
176) -> Result<(), ProofFrameError> {
177    if let Some(field) = object.keys().find(|field| {
178        allowed
179            .binary_search_by(|candidate| candidate.cmp(&field.as_str()))
180            .is_err()
181    }) {
182        return Err(ProofFrameError::contract(
183            ErrorCode::ContractUnknownField,
184            format!("Unknown contract field `{field}`"),
185            Some(append_path(&parent_path, field)),
186        ));
187    }
188    Ok(())
189}
190
191pub(crate) fn column_path(column: &str) -> String {
192    append_path("$.columns", column)
193}
194
195pub(crate) fn append_path(parent: &str, segment: &str) -> String {
196    if is_identifier(segment) {
197        format!("{parent}.{segment}")
198    } else {
199        let encoded = serde_json::to_string(segment).expect("serializing a string cannot fail");
200        format!("{parent}[{encoded}]")
201    }
202}
203
204fn is_identifier(value: &str) -> bool {
205    let mut characters = value.chars();
206    characters
207        .next()
208        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
209        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
210}