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 (fits `i64`/`u64` as parsed).
47pub(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    /// Compile and validate a [`ContractSpec`]. Returns [`FaucetError::Config`]
60    /// on a malformed contract: empty version, no fields, duplicate/empty
61    /// field names, an invalid regex, an empty or type-mismatched `enum`,
62    /// constraints that don't apply to the declared type, or `min > max`.
63    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    /// True when breaches route records to the DLQ (so a DLQ sink is
91    /// required). Checked by the pipeline at run start and by the CLI at
92    /// config-load time.
93    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}