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#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize)]
16pub enum ContractVersion {
17 #[serde(rename = "proofframe.contract.v1")]
19 V1,
20 #[serde(rename = "proofframe.contract.v2")]
22 V2,
23}
24
25#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum NaNPolicyAst {
29 #[default]
31 Reject,
32 Allow,
34}
35
36#[derive(Debug, Clone, PartialEq, Deserialize)]
38#[serde(untagged)]
39pub enum BoundAst {
40 Number(serde_json::Number),
42 Text(String),
44}
45
46impl BoundAst {
47 #[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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct RuleAst {
61 #[serde(default)]
63 pub required: bool,
64 #[serde(default)]
66 pub not_null: bool,
67 #[serde(default)]
69 pub unique: bool,
70 pub min: Option<BoundAst>,
72 pub max: Option<BoundAst>,
74 pub nan: Option<NaNPolicyAst>,
76 pub pattern: Option<String>,
78 pub allowed: Option<BTreeSet<String>>,
80}
81
82#[derive(Debug, Clone, PartialEq, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct ContractAst {
86 pub version: ContractVersion,
88 #[serde(default)]
90 pub columns: BTreeMap<String, RuleAst>,
91 #[serde(default = "default_max_findings")]
93 pub max_findings: usize,
94}
95
96impl ContractAst {
97 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}