use jsoncompat::{StampManifest, stamp_schema};
use jsoncompat_codegen::generate_dataclass_models;
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
#[path = "support/python_env.rs"]
mod python_env;
const UPDATE_ENV: &str = "JSONCOMPAT_UPDATE_DATACLASSES_FIXTURES";
const SNAPSHOT_ROOT: &str = "tests/fixtures/dataclasses";
#[derive(Debug, Clone)]
enum SnapshotKind {
Python,
Error,
}
#[derive(Debug, Clone)]
struct Snapshot {
kind: SnapshotKind,
contents: String,
}
#[test]
fn dataclass_snapshots_are_up_to_date_for_all_sample_schemas() {
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let update = std::env::var_os(UPDATE_ENV).is_some();
let mut expected_paths = BTreeSet::new();
snapshot_backcompat_fixtures(repo_root, update, &mut expected_paths);
snapshot_fuzz_fixtures(repo_root, update, &mut expected_paths);
snapshot_stamp_example(repo_root, update, &mut expected_paths);
prune_or_validate_stale_snapshots(repo_root, update, &expected_paths);
assert_python_syntax(&expected_paths);
}
fn snapshot_backcompat_fixtures(
repo_root: &Path,
update: bool,
expected_paths: &mut BTreeSet<PathBuf>,
) {
let fixture_root = repo_root.join("tests/fixtures/backcompat");
for fixture_dir in sorted_dirs(&fixture_root) {
let case_name = fixture_dir
.file_name()
.and_then(|name| name.to_str())
.expect("utf-8 fixture directory name");
assert_snapshot(
repo_root,
Path::new("backcompat").join(case_name).join("old"),
render_schema_snapshot(&read_json(fixture_dir.join("old.json"))),
update,
expected_paths,
);
assert_snapshot(
repo_root,
Path::new("backcompat").join(case_name).join("new"),
render_schema_snapshot(&read_json(fixture_dir.join("new.json"))),
update,
expected_paths,
);
}
}
fn snapshot_fuzz_fixtures(repo_root: &Path, update: bool, expected_paths: &mut BTreeSet<PathBuf>) {
let fixture_root = repo_root.join("tests/fixtures/fuzz");
for schema_file in sorted_json_files(&fixture_root) {
let relative = schema_file
.strip_prefix(&fixture_root)
.expect("fixture path is under fuzz root");
let schema_doc = read_json(&schema_file);
let schemas = collect_embedded_schemas(&schema_doc);
let snapshot_dir = Path::new("fuzz").join(relative).with_extension("");
for (index, schema) in schemas.into_iter().enumerate() {
assert_snapshot(
repo_root,
snapshot_dir.join(format!("{index:03}")),
render_schema_snapshot(&schema),
update,
expected_paths,
);
}
}
}
fn snapshot_stamp_example(repo_root: &Path, update: bool, expected_paths: &mut BTreeSet<PathBuf>) {
let example_root = repo_root.join("examples/stamp");
let schema_v1 = read_json(example_root.join("schema-v1.json"));
let schema_v2 = read_json(example_root.join("schema-v2.json"));
let result = stamp_schema(
&StampManifest::empty(),
"examples/stamp/user-profile",
schema_v1,
)
.and_then(|first| stamp_schema(&first.manifest, "examples/stamp/user-profile", schema_v2))
.expect("stamp example schemas");
assert_snapshot(
repo_root,
Path::new("examples/stamp/user-profile-writer").to_path_buf(),
render_schema_snapshot(&result.bundle.writer),
update,
expected_paths,
);
assert_snapshot(
repo_root,
Path::new("examples/stamp/user-profile-reader").to_path_buf(),
render_schema_snapshot(&result.bundle.reader),
update,
expected_paths,
);
}
fn render_schema_snapshot(schema: &Value) -> Snapshot {
match generate_dataclass_models(schema) {
Ok(source) => Snapshot {
kind: SnapshotKind::Python,
contents: source,
},
Err(error) => Snapshot {
kind: SnapshotKind::Error,
contents: format!("{error}\n"),
},
}
}
fn assert_snapshot(
repo_root: &Path,
relative_base: PathBuf,
snapshot: Snapshot,
update: bool,
expected_paths: &mut BTreeSet<PathBuf>,
) {
let snapshot_path = repo_root
.join(SNAPSHOT_ROOT)
.join(&relative_base)
.with_extension(snapshot.extension());
let stale_path = repo_root
.join(SNAPSHOT_ROOT)
.join(&relative_base)
.with_extension(snapshot.stale_extension());
expected_paths.insert(snapshot_path.clone());
if update {
if let Some(parent) = snapshot_path.parent() {
fs::create_dir_all(parent).expect("create snapshot directory");
}
fs::write(&snapshot_path, snapshot.contents.as_bytes()).expect("write snapshot fixture");
if stale_path.exists() {
fs::remove_file(&stale_path).expect("remove stale snapshot fixture");
}
}
let current = fs::read_to_string(&snapshot_path).unwrap_or_else(|error| {
panic!(
"missing dataclass snapshot {}: {error}. Run `just regen-dataclasses-fixtures`.",
snapshot_path.display()
)
});
assert_eq!(
normalized_newlines(¤t),
normalized_newlines(&snapshot.contents),
"dataclass snapshot is stale: {}. Run `just regen-dataclasses-fixtures`.",
snapshot_path.display()
);
}
fn normalized_newlines(contents: &str) -> String {
contents.replace("\r\n", "\n")
}
impl Snapshot {
fn extension(&self) -> &'static str {
match self.kind {
SnapshotKind::Python => "py",
SnapshotKind::Error => "error.txt",
}
}
fn stale_extension(&self) -> &'static str {
match self.kind {
SnapshotKind::Python => "error.txt",
SnapshotKind::Error => "py",
}
}
}
fn prune_or_validate_stale_snapshots(
repo_root: &Path,
update: bool,
expected_paths: &BTreeSet<PathBuf>,
) {
let snapshot_root = repo_root.join(SNAPSHOT_ROOT);
if !snapshot_root.exists() {
if update {
fs::create_dir_all(&snapshot_root).expect("create snapshot root");
return;
}
panic!(
"missing dataclass snapshot root {}. Run `just regen-dataclasses-fixtures`.",
snapshot_root.display()
);
}
for file in sorted_files_recursive(&snapshot_root) {
let ext = file.extension().and_then(|ext| ext.to_str());
let file_name = file
.file_name()
.and_then(|name| name.to_str())
.expect("utf-8 snapshot filename");
let is_snapshot = matches!(ext, Some("py")) || file_name.ends_with(".error.txt");
if !is_snapshot || expected_paths.contains(&file) {
continue;
}
if update {
fs::remove_file(&file).expect("remove stale generated snapshot");
} else {
panic!(
"stale dataclass snapshot {}. Run `just regen-dataclasses-fixtures`.",
file.display()
);
}
}
}
fn collect_embedded_schemas(root: &Value) -> Vec<Value> {
match root {
Value::Array(items) => items
.iter()
.filter_map(|item| item.get("schema").cloned())
.collect(),
schema => vec![schema.clone()],
}
}
fn sorted_dirs(root: &Path) -> Vec<PathBuf> {
let mut dirs = fs::read_dir(root)
.unwrap_or_else(|error| panic!("read dir {}: {error}", root.display()))
.map(|entry| entry.expect("read dir entry").path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
dirs.sort();
dirs
}
fn sorted_json_files(root: &Path) -> Vec<PathBuf> {
sorted_files_recursive(root)
.into_iter()
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.collect()
}
fn sorted_files_recursive(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
if path.is_dir() {
for entry in fs::read_dir(&path)
.unwrap_or_else(|error| panic!("read dir {}: {error}", path.display()))
{
stack.push(entry.expect("read dir entry").path());
}
} else {
files.push(path);
}
}
files.sort();
files
}
fn read_json(path: impl AsRef<Path>) -> Value {
serde_json::from_slice(
&fs::read(path.as_ref())
.unwrap_or_else(|error| panic!("read json {}: {error}", path.as_ref().display())),
)
.unwrap_or_else(|error| panic!("parse json {}: {error}", path.as_ref().display()))
}
fn assert_python_syntax(expected_paths: &BTreeSet<PathBuf>) {
let python_paths = expected_paths
.iter()
.filter(|path| path.extension().and_then(|extension| extension.to_str()) == Some("py"))
.collect::<Vec<_>>();
let mut child = python_env::python_command()
.arg("-B")
.arg("-c")
.arg(
"import ast, json, pathlib, sys\nfor raw_path in json.load(sys.stdin):\n path = pathlib.Path(raw_path)\n ast.parse(path.read_text(encoding='utf-8'), filename=str(path))",
)
.stdin(Stdio::piped())
.spawn()
.expect("start python syntax check");
serde_json::to_writer(
child.stdin.take().expect("python syntax check stdin"),
&python_paths,
)
.expect("write generated dataclass paths");
let status = child.wait().expect("run python syntax check");
assert!(
status.success(),
"generated dataclass fixtures did not compile"
);
}