1use crate::contract::config::{ContractFieldType, ContractSpec, FieldContract, OnBreach};
7use crate::error::FaucetError;
8use serde_json::Value;
9use std::collections::HashSet;
10
11#[derive(Debug)]
13pub struct CompiledContract {
14 pub version: String,
16 pub on_breach: OnBreach,
18 pub allow_extra_fields: bool,
20 pub fields: Vec<CompiledField>,
22 pub declared: HashSet<String>,
24}
25
26#[derive(Debug)]
28pub struct CompiledField {
29 pub name: String,
30 pub field_type: ContractFieldType,
31 pub required: bool,
32 pub nullable: bool,
33 pub allowed_values: Option<Vec<Value>>,
34 pub pattern: Option<regex::Regex>,
35 pub min: Option<f64>,
36 pub max: Option<f64>,
37 pub min_length: Option<usize>,
38 pub max_length: Option<usize>,
39}
40
41fn config_err(msg: impl Into<String>) -> FaucetError {
42 FaucetError::Config(format!("contract: {}", msg.into()))
43}
44
45pub(crate) fn type_matches(value: &Value, ty: ContractFieldType) -> bool {
48 match ty {
49 ContractFieldType::String => value.is_string(),
50 ContractFieldType::Integer => value.is_i64() || value.is_u64(),
51 ContractFieldType::Number => value.is_number(),
52 ContractFieldType::Boolean => value.is_boolean(),
53 ContractFieldType::Object => value.is_object(),
54 ContractFieldType::Array => value.is_array(),
55 }
56}
57
58impl CompiledContract {
59 pub fn compile(spec: &ContractSpec) -> Result<Self, FaucetError> {
64 if spec.version.trim().is_empty() {
65 return Err(config_err("`version` must be non-empty"));
66 }
67 if spec.fields.is_empty() {
68 return Err(config_err("`fields` must be non-empty"));
69 }
70 let mut declared: HashSet<String> = HashSet::with_capacity(spec.fields.len());
71 let mut fields = Vec::with_capacity(spec.fields.len());
72 for f in &spec.fields {
73 if f.name.trim().is_empty() {
74 return Err(config_err("field names must be non-empty"));
75 }
76 if !declared.insert(f.name.clone()) {
77 return Err(config_err(format!("duplicate field '{}'", f.name)));
78 }
79 fields.push(compile_field(f)?);
80 }
81 Ok(Self {
82 version: spec.version.clone(),
83 on_breach: spec.on_breach,
84 allow_extra_fields: spec.allow_extra_fields,
85 fields,
86 declared,
87 })
88 }
89
90 pub fn requires_dlq(&self) -> bool {
94 self.on_breach == OnBreach::Quarantine
95 }
96}
97
98fn compile_field(f: &FieldContract) -> Result<CompiledField, FaucetError> {
99 let is_string = f.field_type == ContractFieldType::String;
100 let is_numeric = matches!(
101 f.field_type,
102 ContractFieldType::Integer | ContractFieldType::Number
103 );
104
105 let pattern = match &f.pattern {
106 Some(p) => {
107 if !is_string {
108 return Err(config_err(format!(
109 "field '{}': `pattern` applies only to string fields, not {}",
110 f.name, f.field_type
111 )));
112 }
113 Some(regex::Regex::new(p).map_err(|e| {
114 config_err(format!("field '{}': invalid pattern '{p}': {e}", f.name))
115 })?)
116 }
117 None => None,
118 };
119
120 if (f.min.is_some() || f.max.is_some()) && !is_numeric {
121 return Err(config_err(format!(
122 "field '{}': `min`/`max` apply only to integer/number fields, not {}",
123 f.name, f.field_type
124 )));
125 }
126 if let (Some(lo), Some(hi)) = (f.min, f.max)
127 && lo > hi
128 {
129 return Err(config_err(format!(
130 "field '{}': `min` must be <= `max`",
131 f.name
132 )));
133 }
134
135 if (f.min_length.is_some() || f.max_length.is_some()) && !is_string {
136 return Err(config_err(format!(
137 "field '{}': `min_length`/`max_length` apply only to string fields, not {}",
138 f.name, f.field_type
139 )));
140 }
141 if let (Some(lo), Some(hi)) = (f.min_length, f.max_length)
142 && lo > hi
143 {
144 return Err(config_err(format!(
145 "field '{}': `min_length` must be <= `max_length`",
146 f.name
147 )));
148 }
149
150 if let Some(values) = &f.allowed_values {
151 if values.is_empty() {
152 return Err(config_err(format!(
153 "field '{}': `enum` must be non-empty",
154 f.name
155 )));
156 }
157 for v in values {
158 if v.is_null() {
159 return Err(config_err(format!(
160 "field '{}': `enum` must not contain null — use `nullable: true`",
161 f.name
162 )));
163 }
164 if !type_matches(v, f.field_type) {
165 return Err(config_err(format!(
166 "field '{}': enum value {v} does not match declared type {}",
167 f.name, f.field_type
168 )));
169 }
170 }
171 }
172
173 Ok(CompiledField {
174 name: f.name.clone(),
175 field_type: f.field_type,
176 required: f.required,
177 nullable: f.nullable,
178 allowed_values: f.allowed_values.clone(),
179 pattern,
180 min: f.min,
181 max: f.max,
182 min_length: f.min_length,
183 max_length: f.max_length,
184 })
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use serde_json::json;
191
192 fn spec(v: Value) -> ContractSpec {
193 serde_json::from_value(v).unwrap()
194 }
195
196 fn compile(v: Value) -> Result<CompiledContract, FaucetError> {
197 CompiledContract::compile(&spec(v))
198 }
199
200 fn assert_config_err(r: Result<CompiledContract, FaucetError>, needle: &str) {
201 match r {
202 Err(FaucetError::Config(msg)) => {
203 assert!(msg.contains(needle), "expected '{needle}' in: {msg}")
204 }
205 other => panic!("expected Config error containing '{needle}', got {other:?}"),
206 }
207 }
208
209 #[test]
210 fn compiles_valid_contract() {
211 let c = compile(json!({
212 "version": "1.0.0",
213 "on_breach": "quarantine",
214 "fields": [
215 { "name": "id", "type": "integer", "min": 0 },
216 { "name": "status", "type": "string", "enum": ["a", "b"] }
217 ]
218 }))
219 .unwrap();
220 assert_eq!(c.version, "1.0.0");
221 assert_eq!(c.fields.len(), 2);
222 assert!(c.requires_dlq());
223 assert!(c.declared.contains("status"));
224 }
225
226 #[test]
227 fn requires_dlq_only_for_quarantine() {
228 for (policy, expected) in [("fail", false), ("warn", false), ("quarantine", true)] {
229 let c = compile(json!({
230 "version": "1", "on_breach": policy,
231 "fields": [{ "name": "id", "type": "string" }]
232 }))
233 .unwrap();
234 assert_eq!(c.requires_dlq(), expected, "policy {policy}");
235 }
236 }
237
238 #[test]
239 fn rejects_empty_version() {
240 assert_config_err(
241 compile(json!({"version": " ", "fields": [{"name": "x", "type": "string"}]})),
242 "`version` must be non-empty",
243 );
244 }
245
246 #[test]
247 fn rejects_empty_fields() {
248 assert_config_err(
249 compile(json!({"version": "1", "fields": []})),
250 "`fields` must be non-empty",
251 );
252 }
253
254 #[test]
255 fn rejects_duplicate_field_names() {
256 assert_config_err(
257 compile(json!({"version": "1", "fields": [
258 {"name": "id", "type": "string"},
259 {"name": "id", "type": "integer"}
260 ]})),
261 "duplicate field 'id'",
262 );
263 }
264
265 #[test]
266 fn rejects_empty_field_name() {
267 assert_config_err(
268 compile(json!({"version": "1", "fields": [{"name": " ", "type": "string"}]})),
269 "field names must be non-empty",
270 );
271 }
272
273 #[test]
274 fn rejects_bad_regex() {
275 assert_config_err(
276 compile(json!({"version": "1", "fields": [
277 {"name": "email", "type": "string", "pattern": "[invalid"}
278 ]})),
279 "invalid pattern",
280 );
281 }
282
283 #[test]
284 fn rejects_pattern_on_non_string() {
285 assert_config_err(
286 compile(json!({"version": "1", "fields": [
287 {"name": "n", "type": "integer", "pattern": "^\\d+$"}
288 ]})),
289 "`pattern` applies only to string fields",
290 );
291 }
292
293 #[test]
294 fn rejects_min_max_on_non_numeric() {
295 assert_config_err(
296 compile(json!({"version": "1", "fields": [
297 {"name": "s", "type": "string", "min": 1.0}
298 ]})),
299 "`min`/`max` apply only to integer/number fields",
300 );
301 }
302
303 #[test]
304 fn rejects_min_gt_max() {
305 assert_config_err(
306 compile(json!({"version": "1", "fields": [
307 {"name": "n", "type": "number", "min": 5.0, "max": 1.0}
308 ]})),
309 "`min` must be <= `max`",
310 );
311 }
312
313 #[test]
314 fn rejects_length_on_non_string() {
315 assert_config_err(
316 compile(json!({"version": "1", "fields": [
317 {"name": "n", "type": "number", "min_length": 1}
318 ]})),
319 "`min_length`/`max_length` apply only to string fields",
320 );
321 }
322
323 #[test]
324 fn rejects_min_length_gt_max_length() {
325 assert_config_err(
326 compile(json!({"version": "1", "fields": [
327 {"name": "s", "type": "string", "min_length": 9, "max_length": 3}
328 ]})),
329 "`min_length` must be <= `max_length`",
330 );
331 }
332
333 #[test]
334 fn rejects_empty_enum() {
335 assert_config_err(
336 compile(json!({"version": "1", "fields": [
337 {"name": "s", "type": "string", "enum": []}
338 ]})),
339 "`enum` must be non-empty",
340 );
341 }
342
343 #[test]
344 fn rejects_null_in_enum() {
345 assert_config_err(
346 compile(json!({"version": "1", "fields": [
347 {"name": "s", "type": "string", "enum": ["a", null]}
348 ]})),
349 "must not contain null",
350 );
351 }
352
353 #[test]
354 fn rejects_enum_value_type_mismatch() {
355 assert_config_err(
356 compile(json!({"version": "1", "fields": [
357 {"name": "n", "type": "integer", "enum": [1, "two"]}
358 ]})),
359 "does not match declared type",
360 );
361 }
362
363 #[test]
364 fn integer_type_rejects_float_enum_value() {
365 assert_config_err(
366 compile(json!({"version": "1", "fields": [
367 {"name": "n", "type": "integer", "enum": [1.5]}
368 ]})),
369 "does not match declared type",
370 );
371 }
372
373 #[test]
374 fn number_type_accepts_integer_enum_value() {
375 assert!(
376 compile(json!({"version": "1", "fields": [
377 {"name": "n", "type": "number", "enum": [1, 2.5]}
378 ]}))
379 .is_ok()
380 );
381 }
382
383 #[test]
384 fn type_matches_covers_all_types() {
385 use ContractFieldType::*;
386 assert!(type_matches(&json!("s"), String));
387 assert!(!type_matches(&json!(1), String));
388 assert!(type_matches(&json!(1), Integer));
389 assert!(!type_matches(&json!(1.5), Integer));
390 assert!(type_matches(&json!(1.5), Number));
391 assert!(type_matches(&json!(1), Number));
392 assert!(type_matches(&json!(true), Boolean));
393 assert!(type_matches(&json!({}), Object));
394 assert!(type_matches(&json!([]), Array));
395 assert!(!type_matches(&json!(null), Boolean));
396 }
397}