use std::collections::{BTreeMap, BTreeSet};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::{ErrorCode, ProofFrameError};
const DEFAULT_MAX_FINDINGS: usize = 100;
const ROOT_FIELDS: &[&str] = &["columns", "max_findings", "version"];
const RULE_FIELDS: &[&str] = &[
"allowed", "max", "min", "nan", "not_null", "pattern", "required", "unique",
];
#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize)]
pub enum ContractVersion {
#[serde(rename = "proofframe.contract.v1")]
V1,
#[serde(rename = "proofframe.contract.v2")]
V2,
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NaNPolicyAst {
#[default]
Reject,
Allow,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum BoundAst {
Number(serde_json::Number),
Text(String),
}
impl BoundAst {
#[must_use]
pub fn as_text(&self) -> &str {
match self {
Self::Number(number) => number.as_str(),
Self::Text(text) => text,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuleAst {
#[serde(default)]
pub required: bool,
#[serde(default)]
pub not_null: bool,
#[serde(default)]
pub unique: bool,
pub min: Option<BoundAst>,
pub max: Option<BoundAst>,
pub nan: Option<NaNPolicyAst>,
pub pattern: Option<String>,
pub allowed: Option<BTreeSet<String>>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContractAst {
pub version: ContractVersion,
#[serde(default)]
pub columns: BTreeMap<String, RuleAst>,
#[serde(default = "default_max_findings")]
pub max_findings: usize,
}
impl ContractAst {
pub fn from_json(source: &str) -> Result<Self, ProofFrameError> {
let value: Value = serde_json::from_str(source).map_err(|error| {
ProofFrameError::contract(
ErrorCode::ContractInvalidJson,
format!("Invalid contract JSON: {error}"),
None,
)
})?;
if value.get("status").is_some() {
return Err(ProofFrameError::contract(
ErrorCode::ContractUnknownField,
"The field `status` belongs to V2: set version to \"proofframe.contract.v2\"; omitted Python versions default to V1",
Some("$.version".to_string()),
));
}
validate_known_fields(&value)?;
let contract: Self = serde_json::from_value(value).map_err(|error| {
ProofFrameError::contract(
ErrorCode::ContractInvalidJson,
format!("Invalid contract value: {error}"),
None,
)
})?;
if contract.version != ContractVersion::V1 {
return Err(ProofFrameError::contract(
ErrorCode::ContractInvalidJson,
"ContractAst accepts only proofframe.contract.v1; use ContractDocument for V2",
Some("$.version".to_string()),
));
}
Ok(contract)
}
}
fn default_max_findings() -> usize {
DEFAULT_MAX_FINDINGS
}
fn validate_known_fields(value: &Value) -> Result<(), ProofFrameError> {
let root = value.as_object().ok_or_else(|| {
ProofFrameError::contract(
ErrorCode::ContractInvalidJson,
"A contract document must be a JSON object",
None,
)
})?;
reject_unknown(root, ROOT_FIELDS, "$".to_string())?;
let Some(columns) = root.get("columns") else {
return Ok(());
};
let columns = columns.as_object().ok_or_else(|| {
ProofFrameError::contract(
ErrorCode::ContractInvalidJson,
"Contract field `columns` must be a JSON object",
Some("$.columns".to_string()),
)
})?;
for (column, rule) in columns {
let column_path = append_path("$.columns", column);
let rule = rule.as_object().ok_or_else(|| {
ProofFrameError::contract(
ErrorCode::ContractInvalidJson,
format!("Rules for column `{column}` must be a JSON object"),
Some(column_path.clone()),
)
})?;
reject_unknown(rule, RULE_FIELDS, column_path)?;
}
Ok(())
}
fn reject_unknown(
object: &Map<String, Value>,
allowed: &[&str],
parent_path: String,
) -> Result<(), ProofFrameError> {
if let Some(field) = object.keys().find(|field| {
allowed
.binary_search_by(|candidate| candidate.cmp(&field.as_str()))
.is_err()
}) {
return Err(ProofFrameError::contract(
ErrorCode::ContractUnknownField,
format!("Unknown contract field `{field}`"),
Some(append_path(&parent_path, field)),
));
}
Ok(())
}
pub(crate) fn column_path(column: &str) -> String {
append_path("$.columns", column)
}
pub(crate) fn append_path(parent: &str, segment: &str) -> String {
if is_identifier(segment) {
format!("{parent}.{segment}")
} else {
let encoded = serde_json::to_string(segment).expect("serializing a string cannot fail");
format!("{parent}[{encoded}]")
}
}
fn is_identifier(value: &str) -> bool {
let mut characters = value.chars();
characters
.next()
.is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
}