Skip to main content

faucet_core/contract/
compile.rs

1//! Compile a `ContractSpec` into a `CompiledContract`: validate the version,
2//! field-name uniqueness, per-type constraint compatibility, and bounds, and
3//! compile regexes/enum sets once so the per-record pass is cheap. Failures
4//! surface as `FaucetError::Config` at config-load time, never mid-run.
5
6use crate::contract::config::{ContractFieldType, ContractSpec, FieldContract, OnBreach};
7use crate::error::FaucetError;
8use serde_json::Value;
9use std::collections::HashSet;
10
11/// A fully compiled contract. Built once via [`CompiledContract::compile`].
12#[derive(Debug)]
13pub struct CompiledContract {
14    /// Contract version (carried into breach errors and DLQ envelopes).
15    pub version: String,
16    /// Breach policy.
17    pub on_breach: OnBreach,
18    /// When `false`, undeclared top-level keys breach the contract.
19    pub allow_extra_fields: bool,
20    /// Compiled per-field rules, in declared order (first breach wins).
21    pub fields: Vec<CompiledField>,
22    /// Declared field names, for the O(1) extra-field membership test.
23    pub declared: HashSet<String>,
24}
25
26/// One compiled field rule.
27#[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
45/// Does `value` conform to the declared contract type? `integer` means a JSON
46/// number with no fractional part (`1` and `1.0` both conform).
47pub(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    /// Compile and validate a [`ContractSpec`]. Returns [`FaucetError::Config`]
66    /// on a malformed contract: empty version, no fields, duplicate/empty
67    /// field names, an invalid regex, an empty or type-mismatched `enum`,
68    /// constraints that don't apply to the declared type, or `min > max`.
69    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    /// True when breaches route records to the DLQ (so a DLQ sink is
97    /// required). Checked by the pipeline at run start and by the CLI at
98    /// config-load time.
99    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}