Skip to main content

faucet_cli/pipeline_test/
spec.rs

1//! Serde types for the `faucet test` spec file.
2//!
3//! A spec file declares fixture-based, fully-offline test cases for a
4//! pipeline's deterministic path (transforms → quality → contract). No real
5//! source or sink is ever built: fixture records are streamed through an
6//! in-memory source and captured by an in-memory sink + DLQ.
7
8use crate::config::TransformSpec;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Top-level shape of a `faucet test` spec file (YAML or JSON).
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct TestSpecFile {
17    /// Spec-format version. Must be `1`.
18    pub version: u32,
19    /// The test cases, run in declared order.
20    pub tests: Vec<TestCase>,
21}
22
23/// One fixture-based test case.
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct TestCase {
27    /// Human-readable case name. Must be unique within the spec file.
28    pub name: String,
29
30    /// Path to a pipeline config file (relative paths resolve against the
31    /// spec file's directory). The case runs that config's transforms,
32    /// `quality:`, and `contract:` blocks against the fixture input.
33    /// Mutually exclusive with `pipeline`.
34    #[serde(default)]
35    pub config: Option<String>,
36
37    /// Inline pipeline logic (transforms / quality / contract) — no config
38    /// file needed. Mutually exclusive with `config`.
39    #[serde(default)]
40    pub pipeline: Option<InlinePipeline>,
41
42    /// Matrix row id to test when the referenced config expands to more than
43    /// one invocation. Defaults to the sole invocation; an error names the
44    /// available ids when the config has several and `row` is omitted.
45    #[serde(default)]
46    pub row: Option<String>,
47
48    /// Fixture input: an inline array of JSON records, or a path (string) to
49    /// a `.jsonl` / `.json` / `.yaml` fixture file (relative to the spec
50    /// file's directory).
51    pub input: InputSpec,
52
53    /// Chunk the fixture input into pages of this many records before feeding
54    /// the pipeline. `0` (default) feeds everything as a single page —
55    /// matching `batch_size: 0` semantics for per-page checks (batch quality
56    /// checks and aggregating SQL transforms see the whole input at once).
57    #[serde(default)]
58    pub page_size: usize,
59
60    /// Fixed `${now.*}` clock for this case (RFC3339 like
61    /// `2026-01-31T00:00:00Z`, or a date `2026-01-31`). Overrides the
62    /// command-level `--clock`; defaults to process start (UTC). Set this
63    /// whenever a transform stamps `${now.*}` so the case is deterministic.
64    #[serde(default)]
65    pub clock: Option<String>,
66
67    /// What the case asserts about the run's outcome.
68    pub expect: Expectation,
69}
70
71/// Inline pipeline logic for a test case that doesn't reference a config file.
72#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct InlinePipeline {
75    /// Transform chain, identical to `pipeline.transforms` in a config file.
76    #[serde(default)]
77    pub transforms: Vec<TransformSpec>,
78
79    /// Quality checks, identical to `pipeline.quality` in a config file.
80    /// Quarantined records are capturable via `expect.dlq` / `dlq_count`.
81    #[cfg(feature = "quality")]
82    #[serde(default)]
83    pub quality: Option<faucet_core::QualitySpec>,
84
85    /// Data contract, identical to `pipeline.contract` in a config file.
86    #[cfg(feature = "contract")]
87    #[serde(default)]
88    pub contract: Option<faucet_core::ContractSpec>,
89
90    /// PII masking policy, identical to `pipeline.masking` in a config file.
91    /// Offline there is no destination sink, so every rule applies regardless
92    /// of its `applies_to` scoping.
93    #[cfg(feature = "masking")]
94    #[serde(default)]
95    pub masking: Option<faucet_core::MaskingSpec>,
96}
97
98/// Fixture input — inline records or a fixture-file path.
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
100#[serde(untagged)]
101pub enum InputSpec {
102    /// Inline JSON records.
103    Inline(Vec<Value>),
104    /// Path to a `.jsonl` (one record per line) or `.json` / `.yaml` /
105    /// `.yml` (top-level array) fixture file, relative to the spec file.
106    Path(String),
107}
108
109/// Expected outcome of a test case. Every field is optional but at least one
110/// must be set; all set fields are asserted together.
111#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
112#[serde(deny_unknown_fields)]
113pub struct Expectation {
114    /// The exact records the sink must receive, in order (set
115    /// `unordered: true` to compare as a multiset). Compared per `match`.
116    #[serde(default)]
117    pub records: Option<Vec<Value>>,
118
119    /// The original record payloads that must land in the DLQ (quality /
120    /// contract quarantines), in order. Envelope metadata (timestamps, error
121    /// messages) is not compared — only the quarantined payload itself.
122    #[serde(default)]
123    pub dlq: Option<Vec<Value>>,
124
125    /// Total records the sink must receive (a count-only alternative to
126    /// `records`).
127    #[serde(default)]
128    pub records_written: Option<usize>,
129
130    /// Total DLQ envelopes (a count-only alternative to `dlq`).
131    #[serde(default)]
132    pub dlq_count: Option<usize>,
133
134    /// The run must FAIL, and the error message must contain this substring
135    /// (e.g. a quality `abort` or contract `on_breach: fail`). Without this
136    /// field, a failing run fails the case.
137    #[serde(default)]
138    pub error: Option<String>,
139
140    /// Compare `records` / `dlq` as multisets instead of ordered lists.
141    #[serde(default)]
142    pub unordered: bool,
143
144    /// How individual records are compared.
145    #[serde(default, rename = "match")]
146    pub match_mode: MatchMode,
147}
148
149/// Record-comparison mode for `expect.records` / `expect.dlq`.
150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(rename_all = "snake_case")]
152pub enum MatchMode {
153    /// Expected and actual records must be deeply equal.
154    #[default]
155    Exact,
156    /// Each expected record must be a recursive subset of the actual record:
157    /// every expected object field must be present and match, but the actual
158    /// record may carry extra fields. Arrays still compare element-by-element
159    /// (same length), applying subset semantics to nested objects.
160    Subset,
161}
162
163impl Expectation {
164    /// True when at least one assertion field is set.
165    pub fn has_any(&self) -> bool {
166        self.records.is_some()
167            || self.dlq.is_some()
168            || self.records_written.is_some()
169            || self.dlq_count.is_some()
170            || self.error.is_some()
171    }
172}
173
174impl TestSpecFile {
175    /// Structural validation, run right after parsing. Fail-fast so a broken
176    /// spec never reaches the runner: version, unique non-empty names, the
177    /// config/pipeline exclusivity, a non-empty expectation, and page-size
178    /// bounds all surface here with the spec path in the message.
179    pub fn validate(&self, spec_path: &std::path::Path) -> crate::error::CliResult<()> {
180        let at =
181            |msg: String| crate::error::CliError::Config(format!("{}: {msg}", spec_path.display()));
182        if self.version != 1 {
183            return Err(at(format!(
184                "unsupported test-spec version {} (expected 1)",
185                self.version
186            )));
187        }
188        if self.tests.is_empty() {
189            return Err(at("spec declares no tests".to_string()));
190        }
191        let mut seen = std::collections::HashSet::new();
192        for case in &self.tests {
193            let name = case.name.trim();
194            if name.is_empty() {
195                return Err(at("test case with an empty name".to_string()));
196            }
197            if !seen.insert(name) {
198                return Err(at(format!("duplicate test name '{name}'")));
199            }
200            match (&case.config, &case.pipeline) {
201                (Some(_), Some(_)) => {
202                    return Err(at(format!(
203                        "test '{name}': `config` and `pipeline` are mutually exclusive — pick one"
204                    )));
205                }
206                (None, None) => {
207                    return Err(at(format!(
208                        "test '{name}': one of `config` (a pipeline config path) or `pipeline` \
209                         (inline transforms/quality/contract) is required"
210                    )));
211                }
212                _ => {}
213            }
214            if case.row.is_some() && case.config.is_none() {
215                return Err(at(format!(
216                    "test '{name}': `row` selects a matrix row and requires `config`"
217                )));
218            }
219            if !case.expect.has_any() {
220                return Err(at(format!(
221                    "test '{name}': `expect` must set at least one of records / dlq / \
222                     records_written / dlq_count / error"
223                )));
224            }
225            faucet_core::validate_batch_size(case.page_size)
226                .map_err(|e| at(format!("test '{name}': page_size: {e}")))?;
227        }
228        Ok(())
229    }
230}
231
232/// Parse a spec file (YAML or JSON by extension) and validate it.
233pub fn load_spec(path: &std::path::Path) -> crate::error::CliResult<TestSpecFile> {
234    use crate::error::CliError;
235    let text = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
236        path: path.to_path_buf(),
237        source,
238    })?;
239    let ext = path
240        .extension()
241        .and_then(|e| e.to_str())
242        .map(str::to_ascii_lowercase);
243    let spec: TestSpecFile = match ext.as_deref() {
244        Some("yaml" | "yml") => serde_yaml::from_str(&text).map_err(|e| CliError::ParseConfig {
245            path: path.to_path_buf(),
246            message: e.to_string(),
247        })?,
248        Some("json") => serde_json::from_str(&text).map_err(|e| CliError::ParseConfig {
249            path: path.to_path_buf(),
250            message: e.to_string(),
251        })?,
252        _ => {
253            return Err(CliError::UnknownExtension {
254                path: path.to_path_buf(),
255            });
256        }
257    };
258    spec.validate(path)?;
259    Ok(spec)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serde_json::json;
266    use std::path::Path;
267
268    fn write_spec(dir: &tempfile::TempDir, name: &str, body: &str) -> std::path::PathBuf {
269        let p = dir.path().join(name);
270        std::fs::write(&p, body).unwrap();
271        p
272    }
273
274    #[test]
275    fn parses_minimal_inline_spec() {
276        let dir = tempfile::tempdir().unwrap();
277        let p = write_spec(
278            &dir,
279            "t.yaml",
280            r#"
281version: 1
282tests:
283  - name: passthrough
284    pipeline: {}
285    input: [ { a: 1 } ]
286    expect: { records: [ { a: 1 } ] }
287"#,
288        );
289        let spec = load_spec(&p).unwrap();
290        assert_eq!(spec.tests.len(), 1);
291        assert_eq!(spec.tests[0].name, "passthrough");
292        assert!(matches!(spec.tests[0].input, InputSpec::Inline(ref v) if v.len() == 1));
293        assert_eq!(spec.tests[0].expect.records, Some(vec![json!({"a": 1})]));
294        assert_eq!(spec.tests[0].expect.match_mode, MatchMode::Exact);
295        assert!(!spec.tests[0].expect.unordered);
296    }
297
298    #[test]
299    fn parses_json_spec() {
300        let dir = tempfile::tempdir().unwrap();
301        let p = write_spec(
302            &dir,
303            "t.json",
304            r#"{ "version": 1, "tests": [ { "name": "n", "pipeline": {},
305                 "input": [], "expect": { "records_written": 0 } } ] }"#,
306        );
307        assert_eq!(load_spec(&p).unwrap().tests.len(), 1);
308    }
309
310    #[test]
311    fn rejects_unknown_extension_and_missing_file() {
312        let dir = tempfile::tempdir().unwrap();
313        let p = write_spec(&dir, "t.toml", "version = 1");
314        assert!(matches!(
315            load_spec(&p),
316            Err(crate::error::CliError::UnknownExtension { .. })
317        ));
318        assert!(matches!(
319            load_spec(Path::new("/nonexistent/spec.yaml")),
320            Err(crate::error::CliError::ReadConfig { .. })
321        ));
322    }
323
324    #[test]
325    fn rejects_bad_version_empty_tests_and_duplicates() {
326        let dir = tempfile::tempdir().unwrap();
327        let bad_version = write_spec(
328            &dir,
329            "v.yaml",
330            "version: 2\ntests: [ { name: x, pipeline: {}, input: [], expect: { records_written: 0 } } ]",
331        );
332        let err = load_spec(&bad_version).unwrap_err().to_string();
333        assert!(err.contains("version 2"), "{err}");
334
335        let empty = write_spec(&dir, "e.yaml", "version: 1\ntests: []");
336        assert!(
337            load_spec(&empty)
338                .unwrap_err()
339                .to_string()
340                .contains("no tests")
341        );
342
343        let dup = write_spec(
344            &dir,
345            "d.yaml",
346            r#"
347version: 1
348tests:
349  - { name: same, pipeline: {}, input: [], expect: { records_written: 0 } }
350  - { name: same, pipeline: {}, input: [], expect: { records_written: 0 } }
351"#,
352        );
353        assert!(
354            load_spec(&dup)
355                .unwrap_err()
356                .to_string()
357                .contains("duplicate")
358        );
359    }
360
361    #[test]
362    fn rejects_config_pipeline_conflicts() {
363        let dir = tempfile::tempdir().unwrap();
364        let both = write_spec(
365            &dir,
366            "b.yaml",
367            r#"
368version: 1
369tests:
370  - { name: x, config: p.yaml, pipeline: {}, input: [], expect: { records_written: 0 } }
371"#,
372        );
373        assert!(
374            load_spec(&both)
375                .unwrap_err()
376                .to_string()
377                .contains("mutually exclusive")
378        );
379
380        let neither = write_spec(
381            &dir,
382            "n.yaml",
383            "version: 1\ntests: [ { name: x, input: [], expect: { records_written: 0 } } ]",
384        );
385        assert!(
386            load_spec(&neither)
387                .unwrap_err()
388                .to_string()
389                .contains("is required")
390        );
391    }
392
393    #[test]
394    fn rejects_row_without_config_and_empty_expect() {
395        let dir = tempfile::tempdir().unwrap();
396        let row = write_spec(
397            &dir,
398            "r.yaml",
399            "version: 1\ntests: [ { name: x, pipeline: {}, row: a, input: [], expect: { records_written: 0 } } ]",
400        );
401        assert!(
402            load_spec(&row)
403                .unwrap_err()
404                .to_string()
405                .contains("requires `config`")
406        );
407
408        let empty_expect = write_spec(
409            &dir,
410            "x.yaml",
411            "version: 1\ntests: [ { name: x, pipeline: {}, input: [], expect: {} } ]",
412        );
413        assert!(
414            load_spec(&empty_expect)
415                .unwrap_err()
416                .to_string()
417                .contains("at least one")
418        );
419    }
420
421    #[test]
422    fn rejects_oversized_page_size_and_empty_name() {
423        let dir = tempfile::tempdir().unwrap();
424        let big = write_spec(
425            &dir,
426            "p.yaml",
427            "version: 1\ntests: [ { name: x, pipeline: {}, input: [], page_size: 2000000, expect: { records_written: 0 } } ]",
428        );
429        assert!(
430            load_spec(&big)
431                .unwrap_err()
432                .to_string()
433                .contains("page_size")
434        );
435
436        let unnamed = write_spec(
437            &dir,
438            "u.yaml",
439            "version: 1\ntests: [ { name: '  ', pipeline: {}, input: [], expect: { records_written: 0 } } ]",
440        );
441        assert!(
442            load_spec(&unnamed)
443                .unwrap_err()
444                .to_string()
445                .contains("empty name")
446        );
447    }
448
449    #[test]
450    fn input_path_variant_parses() {
451        let dir = tempfile::tempdir().unwrap();
452        let p = write_spec(
453            &dir,
454            "f.yaml",
455            r#"
456version: 1
457tests:
458  - name: from-file
459    pipeline: {}
460    input: fixtures/records.jsonl
461    expect: { records_written: 2 }
462"#,
463        );
464        let spec = load_spec(&p).unwrap();
465        assert!(
466            matches!(spec.tests[0].input, InputSpec::Path(ref s) if s == "fixtures/records.jsonl")
467        );
468    }
469
470    #[test]
471    fn schema_generates() {
472        let schema = schemars::schema_for!(TestSpecFile);
473        let v = serde_json::to_value(&schema).unwrap();
474        assert!(v["properties"]["tests"].is_object());
475    }
476}