Skip to main content

faucet_cli/pipeline_test/
runner.rs

1//! Offline pipeline execution for `faucet test`.
2//!
3//! Runs the deterministic slice of a pipeline — transforms → quality →
4//! contract — through the *real* `faucet_core::Pipeline` streaming loop, with
5//! the configured source and sink replaced by in-memory fixtures/captures.
6//! Because the genuine per-page code path runs (including DLQ routing and
7//! abort semantics), what a test observes is exactly what production would do
8//! for the same records.
9
10use crate::config::TransformSpec;
11// `CliError` is referenced only inside the `quality`/`contract` cfg blocks —
12// import it there-adjacent via full path so slim builds stay warning-free.
13use crate::error::CliResult;
14use crate::transforms::compile_transforms;
15use async_trait::async_trait;
16use chrono::{DateTime, FixedOffset};
17use faucet_core::observability::Labels;
18use faucet_core::{DlqConfig, FaucetError, Pipeline, Sink, Source, StreamPage};
19use serde_json::Value;
20use std::collections::HashMap;
21use std::pin::Pin;
22use std::sync::{Arc, Mutex};
23
24/// A test case with its pipeline logic and fixtures fully resolved — the
25/// runner's only input, shared by the config-file and inline paths.
26pub struct ResolvedCase {
27    /// Case name (used as the observability row label).
28    pub name: String,
29    /// Transform chain to apply (already layered for config-file cases).
30    pub transforms: Vec<TransformSpec>,
31    /// Quality checks to enforce per page.
32    #[cfg(feature = "quality")]
33    pub quality: Option<faucet_core::QualitySpec>,
34    /// Data contract to enforce per page.
35    #[cfg(feature = "contract")]
36    pub contract: Option<faucet_core::ContractSpec>,
37    /// PII masking policy to apply per page. Offline there is no destination
38    /// sink, so every rule applies regardless of its `applies_to` scoping.
39    #[cfg(feature = "masking")]
40    pub masking: Option<faucet_core::MaskingSpec>,
41    /// Fixture records fed to the pipeline.
42    pub input: Vec<Value>,
43    /// Page size for the fixture source (`0` = single page).
44    pub page_size: usize,
45    /// `${now.*}` clock applied to transform configs.
46    pub clock: DateTime<FixedOffset>,
47}
48
49/// Everything a run produced, for the expectation pass.
50#[derive(Debug)]
51pub struct CaseRun {
52    /// Records the (capturing) sink received, in write order.
53    pub written: Vec<Value>,
54    /// Original payloads of DLQ envelopes, in routing order.
55    pub dlq_payloads: Vec<Value>,
56    /// Count reported by the pipeline (equals `written.len()`).
57    pub records_written: usize,
58    /// The run's error, when it failed (e.g. quality `abort`, contract
59    /// `on_breach: fail`, a transform error).
60    pub error: Option<String>,
61}
62
63/// Execute one resolved case fully offline. Only *setup* problems (an invalid
64/// transform config) surface as `Err`; a failing pipeline run is a legitimate,
65/// assertable outcome and lands in `CaseRun::error`.
66pub async fn run_case(case: &ResolvedCase) -> CliResult<CaseRun> {
67    // Resolve `${now.*}` in transform configs against the case clock — the
68    // same pre-pass `faucet run` applies (crate::executor) — so a `set`
69    // transform stamping `${now.date}` is deterministic under `clock:`.
70    let stages = if case.transforms.is_empty() {
71        Vec::new()
72    } else {
73        let mut transforms = case.transforms.clone();
74        for t in &mut transforms {
75            crate::executor::resolve_now_inplace(&mut t.config, case.clock)?;
76        }
77        compile_transforms(&transforms)?
78    };
79
80    let labels = Labels::new(
81        "faucet-test",
82        case.name.clone(),
83        uuid::Uuid::now_v7().to_string(),
84    );
85    let source: Box<dyn Source> = Box::new(FixtureSource {
86        records: case.input.clone(),
87        page_size: case.page_size,
88    });
89    let source: Box<dyn Source> = if stages.is_empty() {
90        source
91    } else {
92        Box::new(faucet_core::TransformingSource::new(
93            source,
94            stages,
95            labels.clone(),
96        )?)
97    };
98
99    let written = Arc::new(Mutex::new(Vec::new()));
100    let sink = CollectingSink {
101        buffer: Arc::clone(&written),
102        payload_key: None,
103    };
104    // The DLQ capture unwraps each envelope down to its original payload so
105    // expectations compare records, not timestamps/messages.
106    let dlq_payloads = Arc::new(Mutex::new(Vec::new()));
107    let dlq_sink = CollectingSink {
108        buffer: Arc::clone(&dlq_payloads),
109        payload_key: Some("payload"),
110    };
111
112    let pipeline = Pipeline::new(source.as_ref(), &sink)
113        .with_name("faucet-test")
114        .with_row(case.name.clone())
115        // Always attach a capturing DLQ so quality/contract `quarantine`
116        // policies are testable without a `dlq:` block in the config.
117        .with_dlq(DlqConfig::new(Arc::new(dlq_sink)));
118    #[cfg(feature = "quality")]
119    let pipeline = match &case.quality {
120        Some(spec) => {
121            let compiled = faucet_core::CompiledQuality::compile(spec).map_err(|e| {
122                crate::error::CliError::Config(format!("test '{}': quality: {e}", case.name))
123            })?;
124            pipeline.with_quality(Arc::new(compiled))
125        }
126        None => pipeline,
127    };
128    #[cfg(feature = "contract")]
129    let pipeline = match &case.contract {
130        Some(spec) => {
131            let compiled = faucet_core::CompiledContract::compile(spec).map_err(|e| {
132                crate::error::CliError::Config(format!("test '{}': contract: {e}", case.name))
133            })?;
134            pipeline.with_contract(Arc::new(compiled))
135        }
136        None => pipeline,
137    };
138    #[cfg(feature = "masking")]
139    let pipeline = match &case.masking {
140        Some(spec) => {
141            let compiled = faucet_core::CompiledMasking::compile(spec).map_err(|e| {
142                crate::error::CliError::Config(format!("test '{}': masking: {e}", case.name))
143            })?;
144            pipeline.with_masking(Arc::new(compiled))
145        }
146        None => pipeline,
147    };
148
149    let (records_written, error) = match pipeline.run().await {
150        Ok(result) => (result.records_written, None),
151        Err(e) => (0, Some(e.to_string())),
152    };
153
154    // Take the buffers (the pipeline is done; the Arcs are only held here).
155    let written = std::mem::take(&mut *written.lock().expect("buffer lock"));
156    let dlq_payloads = std::mem::take(&mut *dlq_payloads.lock().expect("dlq lock"));
157    let records_written = if error.is_none() {
158        records_written
159    } else {
160        // On failure the pipeline reports no count; what the sink actually
161        // received before the abort is still the honest number.
162        written.len()
163    };
164    Ok(CaseRun {
165        written,
166        dlq_payloads,
167        records_written,
168        error,
169    })
170}
171
172/// In-memory source that streams fixture records, chunked by the case's own
173/// `page_size` (the pipeline's `batch_size` hint is ignored so a test's page
174/// boundaries are exactly what the spec declares).
175struct FixtureSource {
176    records: Vec<Value>,
177    page_size: usize,
178}
179
180#[async_trait]
181impl Source for FixtureSource {
182    async fn fetch_with_context(
183        &self,
184        _context: &HashMap<String, Value>,
185    ) -> Result<Vec<Value>, FaucetError> {
186        Ok(self.records.clone())
187    }
188
189    fn stream_pages<'a>(
190        &'a self,
191        _context: &'a HashMap<String, Value>,
192        _batch_size: usize,
193    ) -> Pin<Box<dyn faucet_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
194        let chunk = if self.page_size == 0 {
195            usize::MAX
196        } else {
197            self.page_size
198        };
199        Box::pin(faucet_core::async_stream::try_stream! {
200            let mut iter = self.records.iter().cloned();
201            loop {
202                let page: Vec<Value> = iter.by_ref().take(chunk).collect();
203                if page.is_empty() {
204                    break;
205                }
206                yield StreamPage { records: page, bookmark: None };
207            }
208        })
209    }
210
211    fn connector_name(&self) -> &'static str {
212        "fixture"
213    }
214}
215
216/// In-memory sink that appends every record to a shared buffer. With
217/// `payload_key` set, it stores only that field of each record — used to
218/// unwrap DLQ envelopes down to the quarantined payload.
219struct CollectingSink {
220    buffer: Arc<Mutex<Vec<Value>>>,
221    payload_key: Option<&'static str>,
222}
223
224#[async_trait]
225impl Sink for CollectingSink {
226    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
227        let mut buf = self.buffer.lock().expect("buffer lock");
228        for r in records {
229            match self.payload_key {
230                Some(key) => buf.push(r.get(key).cloned().unwrap_or_else(|| r.clone())),
231                None => buf.push(r.clone()),
232            }
233        }
234        Ok(records.len())
235    }
236
237    fn connector_name(&self) -> &'static str {
238        "capture"
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serde_json::json;
246
247    fn case(input: Vec<Value>) -> ResolvedCase {
248        ResolvedCase {
249            name: "t".into(),
250            transforms: Vec::new(),
251            #[cfg(feature = "quality")]
252            quality: None,
253            #[cfg(feature = "contract")]
254            contract: None,
255            #[cfg(feature = "masking")]
256            masking: None,
257            input,
258            page_size: 0,
259            clock: chrono::Utc::now().fixed_offset(),
260        }
261    }
262
263    #[tokio::test]
264    async fn passthrough_captures_all_records() {
265        let run = run_case(&case(vec![json!({"a": 1}), json!({"a": 2})]))
266            .await
267            .unwrap();
268        assert_eq!(run.records_written, 2);
269        assert_eq!(run.written, vec![json!({"a": 1}), json!({"a": 2})]);
270        assert!(run.dlq_payloads.is_empty());
271        assert!(run.error.is_none());
272    }
273
274    #[tokio::test]
275    async fn empty_input_yields_empty_run() {
276        let run = run_case(&case(vec![])).await.unwrap();
277        assert_eq!(run.records_written, 0);
278        assert!(run.written.is_empty());
279    }
280
281    #[tokio::test]
282    async fn transforms_apply_in_order() {
283        let mut c = case(vec![json!({"user": {"name": "Ada"}})]);
284        c.transforms = vec![
285            TransformSpec {
286                kind: "flatten".into(),
287                config: json!({"separator": "_"}),
288            },
289            TransformSpec {
290                kind: "set".into(),
291                config: json!({"values": {"src": "fixture"}}),
292            },
293        ];
294        let run = run_case(&c).await.unwrap();
295        assert_eq!(
296            run.written,
297            vec![json!({"user_name": "Ada", "src": "fixture"})]
298        );
299    }
300
301    #[tokio::test]
302    async fn now_tokens_resolve_against_case_clock() {
303        let mut c = case(vec![json!({"a": 1})]);
304        c.clock = chrono::DateTime::parse_from_rfc3339("2026-01-31T00:00:00Z").unwrap();
305        c.transforms = vec![TransformSpec {
306            kind: "set".into(),
307            config: json!({"values": {"day": "${now.date}"}}),
308        }];
309        let run = run_case(&c).await.unwrap();
310        assert_eq!(run.written, vec![json!({"a": 1, "day": "2026-01-31"})]);
311    }
312
313    #[tokio::test]
314    async fn invalid_transform_is_a_setup_error() {
315        let mut c = case(vec![json!({"a": 1})]);
316        c.transforms = vec![TransformSpec {
317            kind: "rename_keys".into(),
318            config: json!({"pattern": "(", "replacement": ""}),
319        }];
320        assert!(run_case(&c).await.is_err());
321    }
322
323    #[tokio::test]
324    async fn page_size_chunks_fixture_pages() {
325        // A page-granular batch quality check observes the page boundary:
326        // with page_size 2 and a 3-record fixture, a `row_count` min of 2
327        // aborts on the second (1-record) page.
328        let mut c = case(vec![json!({"a": 1}), json!({"a": 2}), json!({"a": 3})]);
329        c.page_size = 2;
330        #[cfg(feature = "quality")]
331        {
332            c.quality = Some(
333                serde_json::from_value(json!({
334                    "batch": [ { "type": "row_count", "min": 2, "on_failure": "abort" } ]
335                }))
336                .unwrap(),
337            );
338            let run = run_case(&c).await.unwrap();
339            assert!(
340                run.error.is_some(),
341                "expected quality abort on the short page"
342            );
343            // The first (full) page was written before the abort.
344            assert_eq!(run.written.len(), 2);
345            assert_eq!(run.records_written, 2);
346        }
347    }
348
349    #[cfg(feature = "quality")]
350    #[tokio::test]
351    async fn quality_quarantine_routes_to_dlq_capture() {
352        let mut c = case(vec![json!({"id": 1}), json!({"id": null})]);
353        c.quality = Some(
354            serde_json::from_value(json!({
355                "record": [ { "type": "not_null", "field": "id", "on_failure": "quarantine" } ]
356            }))
357            .unwrap(),
358        );
359        let run = run_case(&c).await.unwrap();
360        assert!(run.error.is_none());
361        assert_eq!(run.written, vec![json!({"id": 1})]);
362        assert_eq!(run.dlq_payloads, vec![json!({"id": null})]);
363    }
364
365    #[cfg(feature = "quality")]
366    #[tokio::test]
367    async fn invalid_quality_spec_is_a_setup_error() {
368        let mut c = case(vec![json!({"a": 1})]);
369        c.quality = Some(
370            serde_json::from_value(json!({
371                "record": [ { "type": "regex_match", "field": "a", "pattern": "(", "on_failure": "abort" } ]
372            }))
373            .unwrap(),
374        );
375        assert!(run_case(&c).await.is_err());
376    }
377
378    #[cfg(feature = "contract")]
379    #[tokio::test]
380    async fn contract_fail_aborts_and_quarantine_routes() {
381        let contract = |on_breach: &str| -> faucet_core::ContractSpec {
382            serde_json::from_value(json!({
383                "version": "1.0.0",
384                "on_breach": on_breach,
385                "fields": [ { "name": "id", "type": "integer", "required": true } ]
386            }))
387            .unwrap()
388        };
389        // fail → run error, nothing written from the breaching page.
390        let mut c = case(vec![json!({"id": "not-an-int"})]);
391        c.contract = Some(contract("fail"));
392        let run = run_case(&c).await.unwrap();
393        assert!(
394            run.error
395                .as_deref()
396                .unwrap_or_default()
397                .contains("Contract v1.0.0 violated"),
398            "unexpected error: {:?}",
399            run.error
400        );
401        assert!(run.written.is_empty());
402        assert_eq!(run.records_written, 0);
403
404        // quarantine → breaching record lands in the DLQ capture.
405        let mut c = case(vec![json!({"id": 7}), json!({"id": "bad"})]);
406        c.contract = Some(contract("quarantine"));
407        let run = run_case(&c).await.unwrap();
408        assert!(run.error.is_none());
409        assert_eq!(run.written, vec![json!({"id": 7})]);
410        assert_eq!(run.dlq_payloads, vec![json!({"id": "bad"})]);
411    }
412}