faucet_cli/pipeline_test/
spec.rs1use crate::config::TransformSpec;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct TestSpecFile {
17 pub version: u32,
19 pub tests: Vec<TestCase>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct TestCase {
27 pub name: String,
29
30 #[serde(default)]
35 pub config: Option<String>,
36
37 #[serde(default)]
40 pub pipeline: Option<InlinePipeline>,
41
42 #[serde(default)]
46 pub row: Option<String>,
47
48 pub input: InputSpec,
52
53 #[serde(default)]
58 pub page_size: usize,
59
60 #[serde(default)]
65 pub clock: Option<String>,
66
67 pub expect: Expectation,
69}
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct InlinePipeline {
75 #[serde(default)]
77 pub transforms: Vec<TransformSpec>,
78
79 #[cfg(feature = "quality")]
82 #[serde(default)]
83 pub quality: Option<faucet_core::QualitySpec>,
84
85 #[cfg(feature = "contract")]
87 #[serde(default)]
88 pub contract: Option<faucet_core::ContractSpec>,
89
90 #[cfg(feature = "masking")]
94 #[serde(default)]
95 pub masking: Option<faucet_core::MaskingSpec>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
100#[serde(untagged)]
101pub enum InputSpec {
102 Inline(Vec<Value>),
104 Path(String),
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
112#[serde(deny_unknown_fields)]
113pub struct Expectation {
114 #[serde(default)]
117 pub records: Option<Vec<Value>>,
118
119 #[serde(default)]
123 pub dlq: Option<Vec<Value>>,
124
125 #[serde(default)]
128 pub records_written: Option<usize>,
129
130 #[serde(default)]
132 pub dlq_count: Option<usize>,
133
134 #[serde(default)]
138 pub error: Option<String>,
139
140 #[serde(default)]
142 pub unordered: bool,
143
144 #[serde(default, rename = "match")]
146 pub match_mode: MatchMode,
147}
148
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(rename_all = "snake_case")]
152pub enum MatchMode {
153 #[default]
155 Exact,
156 Subset,
161}
162
163impl Expectation {
164 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 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
232pub 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}