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