use crate::config::TransformSpec;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TestSpecFile {
pub version: u32,
pub tests: Vec<TestCase>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TestCase {
pub name: String,
#[serde(default)]
pub config: Option<String>,
#[serde(default)]
pub pipeline: Option<InlinePipeline>,
#[serde(default)]
pub row: Option<String>,
pub input: InputSpec,
#[serde(default)]
pub page_size: usize,
#[serde(default)]
pub clock: Option<String>,
pub expect: Expectation,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct InlinePipeline {
#[serde(default)]
pub transforms: Vec<TransformSpec>,
#[cfg(feature = "quality")]
#[serde(default)]
pub quality: Option<faucet_core::QualitySpec>,
#[cfg(feature = "contract")]
#[serde(default)]
pub contract: Option<faucet_core::ContractSpec>,
#[cfg(feature = "masking")]
#[serde(default)]
pub masking: Option<faucet_core::MaskingSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum InputSpec {
Inline(Vec<Value>),
Path(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Expectation {
#[serde(default)]
pub records: Option<Vec<Value>>,
#[serde(default)]
pub dlq: Option<Vec<Value>>,
#[serde(default)]
pub records_written: Option<usize>,
#[serde(default)]
pub dlq_count: Option<usize>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub unordered: bool,
#[serde(default, rename = "match")]
pub match_mode: MatchMode,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MatchMode {
#[default]
Exact,
Subset,
}
impl Expectation {
pub fn has_any(&self) -> bool {
self.records.is_some()
|| self.dlq.is_some()
|| self.records_written.is_some()
|| self.dlq_count.is_some()
|| self.error.is_some()
}
}
impl TestSpecFile {
pub fn validate(&self, spec_path: &std::path::Path) -> crate::error::CliResult<()> {
let at =
|msg: String| crate::error::CliError::Config(format!("{}: {msg}", spec_path.display()));
if self.version != 1 {
return Err(at(format!(
"unsupported test-spec version {} (expected 1)",
self.version
)));
}
if self.tests.is_empty() {
return Err(at("spec declares no tests".to_string()));
}
let mut seen = std::collections::HashSet::new();
for case in &self.tests {
let name = case.name.trim();
if name.is_empty() {
return Err(at("test case with an empty name".to_string()));
}
if !seen.insert(name) {
return Err(at(format!("duplicate test name '{name}'")));
}
match (&case.config, &case.pipeline) {
(Some(_), Some(_)) => {
return Err(at(format!(
"test '{name}': `config` and `pipeline` are mutually exclusive — pick one"
)));
}
(None, None) => {
return Err(at(format!(
"test '{name}': one of `config` (a pipeline config path) or `pipeline` \
(inline transforms/quality/contract) is required"
)));
}
_ => {}
}
if case.row.is_some() && case.config.is_none() {
return Err(at(format!(
"test '{name}': `row` selects a matrix row and requires `config`"
)));
}
if !case.expect.has_any() {
return Err(at(format!(
"test '{name}': `expect` must set at least one of records / dlq / \
records_written / dlq_count / error"
)));
}
faucet_core::validate_batch_size(case.page_size)
.map_err(|e| at(format!("test '{name}': page_size: {e}")))?;
}
Ok(())
}
}
pub fn load_spec(path: &std::path::Path) -> crate::error::CliResult<TestSpecFile> {
use crate::error::CliError;
let text = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
path: path.to_path_buf(),
source,
})?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let spec: TestSpecFile = match ext.as_deref() {
Some("yaml" | "yml") => serde_yaml::from_str(&text).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: e.to_string(),
})?,
Some("json") => serde_json::from_str(&text).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: e.to_string(),
})?,
_ => {
return Err(CliError::UnknownExtension {
path: path.to_path_buf(),
});
}
};
spec.validate(path)?;
Ok(spec)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
fn write_spec(dir: &tempfile::TempDir, name: &str, body: &str) -> std::path::PathBuf {
let p = dir.path().join(name);
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn parses_minimal_inline_spec() {
let dir = tempfile::tempdir().unwrap();
let p = write_spec(
&dir,
"t.yaml",
r#"
version: 1
tests:
- name: passthrough
pipeline: {}
input: [ { a: 1 } ]
expect: { records: [ { a: 1 } ] }
"#,
);
let spec = load_spec(&p).unwrap();
assert_eq!(spec.tests.len(), 1);
assert_eq!(spec.tests[0].name, "passthrough");
assert!(matches!(spec.tests[0].input, InputSpec::Inline(ref v) if v.len() == 1));
assert_eq!(spec.tests[0].expect.records, Some(vec![json!({"a": 1})]));
assert_eq!(spec.tests[0].expect.match_mode, MatchMode::Exact);
assert!(!spec.tests[0].expect.unordered);
}
#[test]
fn parses_json_spec() {
let dir = tempfile::tempdir().unwrap();
let p = write_spec(
&dir,
"t.json",
r#"{ "version": 1, "tests": [ { "name": "n", "pipeline": {},
"input": [], "expect": { "records_written": 0 } } ] }"#,
);
assert_eq!(load_spec(&p).unwrap().tests.len(), 1);
}
#[test]
fn rejects_unknown_extension_and_missing_file() {
let dir = tempfile::tempdir().unwrap();
let p = write_spec(&dir, "t.toml", "version = 1");
assert!(matches!(
load_spec(&p),
Err(crate::error::CliError::UnknownExtension { .. })
));
assert!(matches!(
load_spec(Path::new("/nonexistent/spec.yaml")),
Err(crate::error::CliError::ReadConfig { .. })
));
}
#[test]
fn rejects_bad_version_empty_tests_and_duplicates() {
let dir = tempfile::tempdir().unwrap();
let bad_version = write_spec(
&dir,
"v.yaml",
"version: 2\ntests: [ { name: x, pipeline: {}, input: [], expect: { records_written: 0 } } ]",
);
let err = load_spec(&bad_version).unwrap_err().to_string();
assert!(err.contains("version 2"), "{err}");
let empty = write_spec(&dir, "e.yaml", "version: 1\ntests: []");
assert!(
load_spec(&empty)
.unwrap_err()
.to_string()
.contains("no tests")
);
let dup = write_spec(
&dir,
"d.yaml",
r#"
version: 1
tests:
- { name: same, pipeline: {}, input: [], expect: { records_written: 0 } }
- { name: same, pipeline: {}, input: [], expect: { records_written: 0 } }
"#,
);
assert!(
load_spec(&dup)
.unwrap_err()
.to_string()
.contains("duplicate")
);
}
#[test]
fn rejects_config_pipeline_conflicts() {
let dir = tempfile::tempdir().unwrap();
let both = write_spec(
&dir,
"b.yaml",
r#"
version: 1
tests:
- { name: x, config: p.yaml, pipeline: {}, input: [], expect: { records_written: 0 } }
"#,
);
assert!(
load_spec(&both)
.unwrap_err()
.to_string()
.contains("mutually exclusive")
);
let neither = write_spec(
&dir,
"n.yaml",
"version: 1\ntests: [ { name: x, input: [], expect: { records_written: 0 } } ]",
);
assert!(
load_spec(&neither)
.unwrap_err()
.to_string()
.contains("is required")
);
}
#[test]
fn rejects_row_without_config_and_empty_expect() {
let dir = tempfile::tempdir().unwrap();
let row = write_spec(
&dir,
"r.yaml",
"version: 1\ntests: [ { name: x, pipeline: {}, row: a, input: [], expect: { records_written: 0 } } ]",
);
assert!(
load_spec(&row)
.unwrap_err()
.to_string()
.contains("requires `config`")
);
let empty_expect = write_spec(
&dir,
"x.yaml",
"version: 1\ntests: [ { name: x, pipeline: {}, input: [], expect: {} } ]",
);
assert!(
load_spec(&empty_expect)
.unwrap_err()
.to_string()
.contains("at least one")
);
}
#[test]
fn rejects_oversized_page_size_and_empty_name() {
let dir = tempfile::tempdir().unwrap();
let big = write_spec(
&dir,
"p.yaml",
"version: 1\ntests: [ { name: x, pipeline: {}, input: [], page_size: 2000000, expect: { records_written: 0 } } ]",
);
assert!(
load_spec(&big)
.unwrap_err()
.to_string()
.contains("page_size")
);
let unnamed = write_spec(
&dir,
"u.yaml",
"version: 1\ntests: [ { name: ' ', pipeline: {}, input: [], expect: { records_written: 0 } } ]",
);
assert!(
load_spec(&unnamed)
.unwrap_err()
.to_string()
.contains("empty name")
);
}
#[test]
fn input_path_variant_parses() {
let dir = tempfile::tempdir().unwrap();
let p = write_spec(
&dir,
"f.yaml",
r#"
version: 1
tests:
- name: from-file
pipeline: {}
input: fixtures/records.jsonl
expect: { records_written: 2 }
"#,
);
let spec = load_spec(&p).unwrap();
assert!(
matches!(spec.tests[0].input, InputSpec::Path(ref s) if s == "fixtures/records.jsonl")
);
}
#[test]
fn schema_generates() {
let schema = schemars::schema_for!(TestSpecFile);
let v = serde_json::to_value(&schema).unwrap();
assert!(v["properties"]["tests"].is_object());
}
}