use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use crate::error::{CliError, CliResult};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SuiteFile {
pub version: u32,
pub template: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub select: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sink: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sink_select: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay_select: Option<String>,
pub suite: Suite,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Suite {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cases: Vec<Case>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub combine: Option<Combine>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto: Option<Auto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub behavioral: Vec<Behavioral>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Case {
pub name: String,
#[serde(default)]
pub params: BTreeMap<String, Value>,
#[serde(default)]
pub expect: Expect,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Expect {
#[serde(default = "default_true")]
pub valid: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
fn default_true() -> bool {
true
}
impl Default for Expect {
fn default() -> Self {
Self {
valid: default_true(),
error: None,
}
}
}
impl Expect {
pub fn expects_failure(&self) -> bool {
self.error.is_some() || !self.valid
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Combine {
pub params: BTreeMap<String, Vec<Value>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<BTreeMap<String, Value>>,
#[serde(default)]
pub pairwise: bool,
#[serde(default)]
pub expect: Expect,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Auto {
#[serde(default)]
pub enum_coverage: bool,
#[serde(default)]
pub required_omitted: bool,
#[serde(default)]
pub defaults_baseline: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Behavioral {
pub name: String,
#[serde(default)]
pub params: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_size: Option<usize>,
pub input: Value,
pub expect: Value,
}
pub const MAX_CASES: usize = 512;
impl SuiteFile {
pub fn parse(text: &str) -> CliResult<Self> {
let file: SuiteFile = serde_yaml::from_str(text)
.map_err(|e| CliError::Config(format!("template test suite: {e}")))?;
file.validate()?;
Ok(file)
}
pub fn from_path(path: &std::path::Path) -> CliResult<Self> {
let text = std::fs::read_to_string(path).map_err(|e| {
CliError::Config(format!("template test suite {}: {e}", path.display()))
})?;
Self::parse(&text)
}
fn validate(&self) -> CliResult<()> {
if self.version != 1 {
return Err(CliError::Config(format!(
"template test suite: unsupported version {} (expected 1)",
self.version
)));
}
if self.template.trim().is_empty() {
return Err(CliError::Config(
"template test suite: `template` must name a registered id or a config path".into(),
));
}
let auto_on = self
.suite
.auto
.as_ref()
.is_some_and(|a| a.enum_coverage || a.required_omitted || a.defaults_baseline);
if self.suite.cases.is_empty()
&& self.suite.combine.is_none()
&& self.suite.behavioral.is_empty()
&& !auto_on
{
return Err(CliError::Config(
"template test suite: no cases — add `cases:`, `combine:`, `auto:` or \
`behavioral:`. An empty suite reports green, which is worse than no suite."
.into(),
));
}
for c in &self.suite.cases {
if c.name.trim().is_empty() {
return Err(CliError::Config(
"template test suite: every case needs a non-empty `name`".into(),
));
}
}
if let Some(cb) = &self.suite.combine {
if cb.params.is_empty() {
return Err(CliError::Config(
"template test suite: `combine.params` is empty — nothing to generate".into(),
));
}
for (name, values) in &cb.params {
if values.is_empty() {
return Err(CliError::Config(format!(
"template test suite: `combine.params.{name}` is an empty list, which \
would generate zero cases for every other param too"
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_full_suite() {
let s = SuiteFile::parse(
r#"
version: 1
template: sf-to-bq
select: newest
suite:
cases:
- name: jsonl-account
params: { sink: jsonl, object: Account }
combine:
params:
sink: [jsonl, bigquery]
object: [Account, Contact]
exclude:
- { sink: bigquery, object: Contact }
pairwise: false
auto:
enum_coverage: true
required_omitted: true
defaults_baseline: true
behavioral:
- name: flow
params: { sink: jsonl }
input: [{ "Id": "1" }]
expect: { records_written: 1 }
"#,
)
.expect("parses");
assert_eq!(s.template, "sf-to-bq");
assert_eq!(s.suite.cases.len(), 1);
assert_eq!(s.suite.combine.as_ref().unwrap().params.len(), 2);
assert!(s.suite.auto.as_ref().unwrap().enum_coverage);
assert_eq!(s.suite.behavioral.len(), 1);
}
#[test]
fn an_empty_suite_is_rejected() {
let err = SuiteFile::parse("version: 1\ntemplate: t\nsuite: {}\n")
.expect_err("empty suite must be rejected");
assert!(err.to_string().contains("no cases"), "{err}");
}
#[test]
fn an_unknown_version_is_rejected() {
let err = SuiteFile::parse("version: 2\ntemplate: t\nsuite: { cases: [{ name: a }] }\n")
.expect_err("version 2");
assert!(err.to_string().contains("version 2"), "{err}");
}
#[test]
fn an_empty_combine_list_is_rejected() {
let err = SuiteFile::parse(
"version: 1\ntemplate: t\nsuite:\n combine:\n params:\n sink: []\n",
)
.expect_err("empty list");
assert!(err.to_string().contains("empty list"), "{err}");
}
#[test]
fn an_unknown_key_is_rejected() {
let err =
SuiteFile::parse("version: 1\ntemplate: t\nsuit: { cases: [] }\n").expect_err("typo");
assert!(err.to_string().contains("suit"), "{err}");
}
#[test]
fn expect_defaults_to_valid() {
let e = Expect::default();
assert!(e.valid);
assert!(!e.expects_failure());
let failing = Expect {
valid: true,
error: Some("required".into()),
};
assert!(
failing.expects_failure(),
"an `error:` expectation means the case must fail"
);
}
#[test]
fn an_empty_template_reference_is_rejected() {
let err =
SuiteFile::parse("version: 1\ntemplate: \" \"\nsuite:\n cases:\n - name: a\n")
.expect_err("blank template");
assert!(err.to_string().contains("`template` must name"), "{err}");
}
#[test]
fn a_case_without_a_name_is_rejected() {
let err =
SuiteFile::parse("version: 1\ntemplate: t\nsuite:\n cases:\n - name: \" \"\n")
.expect_err("blank case name");
assert!(err.to_string().contains("non-empty `name`"), "{err}");
}
#[test]
fn an_empty_combine_params_map_is_rejected() {
let err = SuiteFile::parse("version: 1\ntemplate: t\nsuite:\n combine:\n params: {}\n")
.expect_err("no axes");
assert!(err.to_string().contains("nothing to generate"), "{err}");
}
}