1pub mod compile;
11pub mod config;
12pub mod export;
13
14pub use compile::{CompiledContract, CompiledField};
15pub use config::{ContractFieldType, ContractSpec, FieldContract, OnBreach};
16pub use export::{OPENLINEAGE_SCHEMA_FACET_URL, to_json_schema, to_openlineage_facet};
17
18use crate::contract::compile::type_matches;
19use crate::error::FaucetError;
20use serde_json::Value;
21
22#[derive(Debug, Clone)]
24pub struct ContractViolation {
25 pub rule: &'static str,
29 pub field: Option<String>,
31 pub message: String,
33 pub page_index: usize,
36}
37
38impl ContractViolation {
39 pub fn describe(&self) -> String {
41 match &self.field {
42 Some(f) => format!("field '{f}': {} ({})", self.message, self.rule),
43 None => format!("{} ({})", self.message, self.rule),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
51pub struct ViolatingRecord {
52 pub record: Value,
53 pub violation: ContractViolation,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct ContractOutcome {
59 pub survivors: Vec<Value>,
61 pub quarantined: Vec<ViolatingRecord>,
63 pub warned: Vec<ContractViolation>,
65}
66
67pub fn apply_contract(
75 records: Vec<Value>,
76 contract: &CompiledContract,
77) -> Result<ContractOutcome, FaucetError> {
78 let mut out = ContractOutcome::default();
79 for (page_index, rec) in records.into_iter().enumerate() {
80 match evaluate_record(&rec, contract, page_index) {
81 None => out.survivors.push(rec),
82 Some(violation) => match contract.on_breach {
83 OnBreach::Fail => {
84 return Err(FaucetError::ContractViolation {
85 version: contract.version.clone(),
86 message: violation.describe(),
87 });
88 }
89 OnBreach::Quarantine => out.quarantined.push(ViolatingRecord {
90 record: rec,
91 violation,
92 }),
93 OnBreach::Warn => {
94 out.warned.push(violation);
95 out.survivors.push(rec);
96 }
97 },
98 }
99 }
100 Ok(out)
101}
102
103fn evaluate_record(
106 rec: &Value,
107 contract: &CompiledContract,
108 page_index: usize,
109) -> Option<ContractViolation> {
110 let violation = |rule: &'static str, field: Option<&str>, message: String| ContractViolation {
111 rule,
112 field: field.map(str::to_string),
113 message,
114 page_index,
115 };
116
117 let Some(obj) = rec.as_object() else {
118 return Some(violation(
119 "not_object",
120 None,
121 format!("record is not a JSON object (got {})", json_type_name(rec)),
122 ));
123 };
124
125 for f in &contract.fields {
126 let value = match obj.get(&f.name) {
127 None => {
128 if f.required {
129 return Some(violation(
130 "missing",
131 Some(&f.name),
132 "required field is missing".into(),
133 ));
134 }
135 continue; }
137 Some(v) => v,
138 };
139 if value.is_null() {
140 if f.nullable {
141 continue; }
143 return Some(violation(
144 "null",
145 Some(&f.name),
146 "field is null but not nullable".into(),
147 ));
148 }
149 if !type_matches(value, f.field_type) {
150 return Some(violation(
151 "type",
152 Some(&f.name),
153 format!("expected {}, got {}", f.field_type, json_type_name(value)),
154 ));
155 }
156 if let Some(values) = &f.allowed_values
157 && !enum_contains(values, value)
158 {
159 return Some(violation(
160 "enum",
161 Some(&f.name),
162 format!("value {value} is not in the allowed set"),
163 ));
164 }
165 if let Some(re) = &f.pattern {
166 let s = value.as_str().unwrap_or_default();
168 if !re.is_match(s) {
169 return Some(violation(
170 "pattern",
171 Some(&f.name),
172 format!("value {value} does not match pattern '{re}'"),
173 ));
174 }
175 }
176 if (f.min.is_some() || f.max.is_some())
177 && let Some(n) = value.as_f64()
178 {
179 if let Some(min) = f.min
180 && n < min
181 {
182 return Some(violation(
183 "range",
184 Some(&f.name),
185 format!("value {n} is below the minimum {min}"),
186 ));
187 }
188 if let Some(max) = f.max
189 && n > max
190 {
191 return Some(violation(
192 "range",
193 Some(&f.name),
194 format!("value {n} is above the maximum {max}"),
195 ));
196 }
197 }
198 if f.min_length.is_some() || f.max_length.is_some() {
199 let len = value.as_str().map(|s| s.chars().count()).unwrap_or(0);
200 if let Some(min) = f.min_length
201 && len < min
202 {
203 return Some(violation(
204 "length",
205 Some(&f.name),
206 format!("string length {len} is below the minimum {min}"),
207 ));
208 }
209 if let Some(max) = f.max_length
210 && len > max
211 {
212 return Some(violation(
213 "length",
214 Some(&f.name),
215 format!("string length {len} is above the maximum {max}"),
216 ));
217 }
218 }
219 }
220
221 if !contract.allow_extra_fields {
222 for key in obj.keys() {
223 if !contract.declared.contains(key) {
224 return Some(violation(
225 "extra_field",
226 Some(key),
227 "field is not declared in the contract and allow_extra_fields is false".into(),
228 ));
229 }
230 }
231 }
232
233 None
234}
235
236fn json_type_name(v: &Value) -> &'static str {
237 match v {
238 Value::Null => "null",
239 Value::Bool(_) => "boolean",
240 Value::Number(_) => "number",
241 Value::String(_) => "string",
242 Value::Array(_) => "array",
243 Value::Object(_) => "object",
244 }
245}
246
247fn enum_contains(allowed: &[Value], value: &Value) -> bool {
253 allowed.iter().any(|v| match (v, value) {
254 (Value::Number(a), Value::Number(b)) => {
255 if let (Some(x), Some(y)) = (a.as_i64(), b.as_i64()) {
256 x == y
257 } else if let (Some(x), Some(y)) = (a.as_u64(), b.as_u64()) {
258 x == y
259 } else {
260 matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
261 }
262 }
263 _ => v == value,
264 })
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use serde_json::json;
271
272 fn compiled(v: Value) -> CompiledContract {
273 let spec: ContractSpec = serde_json::from_value(v).unwrap();
274 CompiledContract::compile(&spec).unwrap()
275 }
276
277 fn basic(on_breach: &str) -> CompiledContract {
278 compiled(json!({
279 "version": "1.0.0",
280 "on_breach": on_breach,
281 "fields": [
282 { "name": "id", "type": "integer", "min": 0 },
283 { "name": "status", "type": "string", "enum": ["open", "closed"] },
284 { "name": "email", "type": "string", "required": false,
285 "nullable": true, "pattern": "^.+@.+$", "min_length": 3 }
286 ]
287 }))
288 }
289
290 #[test]
291 fn conforming_page_passes_untouched() {
292 let page = vec![
293 json!({"id": 1, "status": "open", "email": "a@b.c"}),
294 json!({"id": 2, "status": "closed"}), json!({"id": 3, "status": "open", "email": null}), ];
297 let out = apply_contract(page.clone(), &basic("fail")).unwrap();
298 assert_eq!(out.survivors, page);
299 assert!(out.quarantined.is_empty());
300 assert!(out.warned.is_empty());
301 }
302
303 #[test]
304 fn numeric_enum_matches_across_int_float_spelling() {
305 let c = compiled(json!({
308 "version": "1.0.0",
309 "on_breach": "fail",
310 "fields": [ { "name": "rate", "type": "number", "enum": [1, 2] } ]
311 }));
312 let out = apply_contract(vec![json!({"rate": 1.0})], &c).unwrap();
314 assert_eq!(out.survivors.len(), 1);
315 assert!(out.quarantined.is_empty());
316 let err = apply_contract(vec![json!({"rate": 3.0})], &c).unwrap_err();
318 assert!(matches!(err, FaucetError::ContractViolation { .. }));
319 }
320
321 #[test]
322 fn fail_raises_typed_error_with_version_and_field() {
323 let page = vec![
324 json!({"id": 1, "status": "open"}),
325 json!({"id": 2, "status": "bogus"}),
326 ];
327 let err = apply_contract(page, &basic("fail")).unwrap_err();
328 match err {
329 FaucetError::ContractViolation { version, message } => {
330 assert_eq!(version, "1.0.0");
331 assert!(message.contains("status"), "message: {message}");
332 assert!(message.contains("enum"), "message: {message}");
333 }
334 other => panic!("expected ContractViolation, got {other:?}"),
335 }
336 }
337
338 #[test]
339 fn quarantine_partitions_page_and_keeps_page_index() {
340 let page = vec![
341 json!({"id": 1, "status": "open"}),
342 json!({"id": -5, "status": "open"}), json!({"id": 3, "status": "open"}),
344 json!({"id": 4, "status": "nope"}), ];
346 let out = apply_contract(page, &basic("quarantine")).unwrap();
347 assert_eq!(out.survivors.len(), 2);
348 let idx: Vec<usize> = out
349 .quarantined
350 .iter()
351 .map(|q| q.violation.page_index)
352 .collect();
353 assert_eq!(idx, vec![1, 3], "page_index must be the original position");
354 assert_eq!(out.quarantined[0].violation.rule, "range");
355 assert_eq!(out.quarantined[1].violation.rule, "enum");
356 }
357
358 #[test]
359 fn warn_keeps_all_records_and_reports_breaches() {
360 let page = vec![
361 json!({"id": 1, "status": "open"}),
362 json!({"id": "not-an-int", "status": "open"}),
363 ];
364 let out = apply_contract(page.clone(), &basic("warn")).unwrap();
365 assert_eq!(out.survivors, page, "warn must not remove records");
366 assert!(out.quarantined.is_empty());
367 assert_eq!(out.warned.len(), 1);
368 assert_eq!(out.warned[0].rule, "type");
369 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
370 assert_eq!(out.warned[0].page_index, 1);
371 }
372
373 #[test]
374 fn missing_required_field_breaches() {
375 let out = apply_contract(vec![json!({"status": "open"})], &basic("warn")).unwrap();
376 assert_eq!(out.warned[0].rule, "missing");
377 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
378 }
379
380 #[test]
381 fn null_on_non_nullable_breaches() {
382 let out =
383 apply_contract(vec![json!({"id": null, "status": "open"})], &basic("warn")).unwrap();
384 assert_eq!(out.warned[0].rule, "null");
385 }
386
387 #[test]
388 fn pattern_and_length_breaches() {
389 let c = basic("warn");
390 let out = apply_contract(
391 vec![json!({"id": 1, "status": "open", "email": "nope"})],
392 &c,
393 )
394 .unwrap();
395 assert_eq!(out.warned[0].rule, "pattern");
396 let out =
397 apply_contract(vec![json!({"id": 1, "status": "open", "email": "@b"})], &c).unwrap();
398 assert_eq!(out.warned[0].rule, "pattern");
401 let out =
402 apply_contract(vec![json!({"id": 1, "status": "open", "email": "a@c"})], &c).unwrap();
403 assert!(out.warned.is_empty(), "a@c matches pattern and min_length");
404 }
405
406 #[test]
407 fn min_length_breach_reported() {
408 let c = compiled(json!({
409 "version": "1", "on_breach": "warn",
410 "fields": [{ "name": "s", "type": "string", "min_length": 3, "max_length": 5 }]
411 }));
412 let out = apply_contract(vec![json!({"s": "ab"})], &c).unwrap();
413 assert_eq!(out.warned[0].rule, "length");
414 let out = apply_contract(vec![json!({"s": "abcdef"})], &c).unwrap();
415 assert_eq!(out.warned[0].rule, "length");
416 let out = apply_contract(vec![json!({"s": "héllo"})], &c).unwrap();
418 assert!(out.warned.is_empty());
419 }
420
421 #[test]
422 fn integer_type_rejects_float_value() {
423 let out =
424 apply_contract(vec![json!({"id": 1.5, "status": "open"})], &basic("warn")).unwrap();
425 assert_eq!(out.warned[0].rule, "type");
426 }
427
428 #[test]
429 fn extra_field_breach_when_disallowed() {
430 let c = compiled(json!({
431 "version": "1", "on_breach": "warn", "allow_extra_fields": false,
432 "fields": [{ "name": "id", "type": "integer" }]
433 }));
434 let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
435 assert_eq!(out.warned[0].rule, "extra_field");
436 assert_eq!(out.warned[0].field.as_deref(), Some("surprise"));
437 }
438
439 #[test]
440 fn extra_field_allowed_by_default() {
441 let c = compiled(json!({
442 "version": "1", "on_breach": "warn",
443 "fields": [{ "name": "id", "type": "integer" }]
444 }));
445 let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
446 assert!(out.warned.is_empty());
447 }
448
449 #[test]
450 fn non_object_record_breaches() {
451 let out = apply_contract(vec![json!([1, 2, 3])], &basic("warn")).unwrap();
452 assert_eq!(out.warned[0].rule, "not_object");
453 assert!(out.warned[0].field.is_none());
454 }
455
456 #[test]
457 fn first_breach_wins_in_declared_field_order() {
458 let out =
460 apply_contract(vec![json!({"id": "x", "status": "nope"})], &basic("warn")).unwrap();
461 assert_eq!(out.warned.len(), 1, "one breach per record");
462 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
463 }
464
465 #[test]
466 fn range_bounds_inclusive() {
467 let c = compiled(json!({
468 "version": "1", "on_breach": "warn",
469 "fields": [{ "name": "n", "type": "number", "min": 0.0, "max": 10.0 }]
470 }));
471 let out = apply_contract(vec![json!({"n": 0.0}), json!({"n": 10.0})], &c).unwrap();
472 assert!(out.warned.is_empty(), "bounds are inclusive");
473 let out = apply_contract(vec![json!({"n": 10.001})], &c).unwrap();
474 assert_eq!(out.warned[0].rule, "range");
475 }
476
477 #[test]
478 fn violation_describe_includes_field_and_rule() {
479 let v = ContractViolation {
480 rule: "enum",
481 field: Some("status".into()),
482 message: "value \"x\" is not in the allowed set".into(),
483 page_index: 0,
484 };
485 let d = v.describe();
486 assert!(d.contains("field 'status'"));
487 assert!(d.contains("(enum)"));
488 let v2 = ContractViolation {
489 rule: "not_object",
490 field: None,
491 message: "record is not a JSON object (got array)".into(),
492 page_index: 0,
493 };
494 assert!(!v2.describe().contains("field"));
495 }
496}