use std::sync::LazyLock;
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::{json, toml, xml, yaml};
use omnist::oml;
use omnist::ops::{compatible_with, extract, is_empty, is_isomorphic, normalize, prune};
use omnist::osd;
use omnist::schema::{self, Field, FieldType, Record, Ref, Schema};
use proptest::prelude::*;
use proptest::strategy::ValueTree;
use proptest::test_runner::TestRunner;
static CASES: LazyLock<u32> = LazyLock::new(|| {
std::env::var("PROPTEST_CASES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(150)
});
fn arb_label() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_]{0,5}"
}
fn arb_scalar() -> impl Strategy<Value = Value> {
prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
(-1_000_000i64..1_000_000).prop_map(|i: i64| Value::Int(i.into())),
prop_oneof![Just(i64::MAX), Just(i64::MIN), Just(0i64), Just(-1i64),]
.prop_map(|i: i64| Value::Int(i.into())),
(-1e6f64..1e6)
.prop_filter("finite", |f| f.is_finite())
.prop_map(Value::Float),
"[a-zA-Z0-9 _.-]{0,12}".prop_map(Value::Str),
arb_tricky_string().prop_map(Value::Str),
arb_temporal_string().prop_map(Value::Str),
]
}
fn arb_tricky_string() -> impl Strategy<Value = String> {
prop_oneof![
Just("a: b".to_string()),
Just("- item".to_string()),
Just("\"quoted\\path\"".to_string()),
Just("<tag>&</tag>".to_string()),
Just("line1\nline2".to_string()),
Just("tab\there".to_string()),
Just("both \" and '".to_string()),
Just("\u{0085}".to_string()), Just(String::new()),
]
}
fn arb_temporal_string() -> impl Strategy<Value = String> {
prop_oneof![
Just("2024-02-29".to_string()), Just("2023-02-28".to_string()),
Just("0001-01-01".to_string()),
Just("9999-12-31".to_string()),
Just("00:00:00".to_string()),
Just("23:59:59".to_string()),
Just("23:59:59.999999".to_string()),
Just("2024-01-01T00:00:00".to_string()),
Just("2024-12-31T23:59:59.500000".to_string()),
]
}
fn arb_value(depth: u32) -> BoxedStrategy<Value> {
if depth == 0 {
arb_scalar().boxed()
} else {
let leaf = arb_scalar();
let recurse = arb_value(depth - 1);
prop_oneof![
2 => leaf,
3 => proptest::collection::vec((arb_label(), recurse.clone()), 0..4)
.prop_map(|pairs| {
let mut map = IndexMap::new();
for (k, v) in pairs {
map.insert(k, v);
}
Value::Object(map)
}),
]
.boxed()
}
}
fn arb_object_value(depth: u32) -> impl Strategy<Value = Value> {
proptest::collection::vec((arb_label(), arb_value(depth)), 1..4).prop_map(|pairs| {
let mut map = IndexMap::new();
for (k, v) in pairs {
map.insert(k, v);
}
Value::Object(map)
})
}
fn arb_xml_scalar() -> impl Strategy<Value = Value> {
prop_oneof![
"[a-zA-Z0-9 _.-]{0,12}".prop_map(Value::Str),
arb_tricky_string().prop_map(Value::Str),
arb_temporal_string().prop_map(Value::Str),
]
}
fn arb_xml_value(depth: u32) -> BoxedStrategy<Value> {
if depth == 0 {
arb_xml_scalar().boxed()
} else {
let leaf = arb_xml_scalar();
let recurse = arb_xml_value(depth - 1);
prop_oneof![
2 => leaf,
3 => proptest::collection::vec((arb_label(), recurse.clone()), 0..4)
.prop_map(|pairs| {
let mut map = IndexMap::new();
for (k, v) in pairs {
map.insert(k, v);
}
Value::Object(map)
}),
]
.boxed()
}
}
fn arb_single_root_xml_value(depth: u32) -> impl Strategy<Value = Value> {
(arb_label(), arb_xml_value(depth)).prop_map(|(k, v)| {
let mut map = IndexMap::new();
map.insert(k, v);
Value::Object(map)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(*CASES))]
#[test]
fn oml_round_trips(v in arb_object_value(3)) {
let doc = Doc::of(&v).unwrap();
let raw = doc.to_raw();
let text = oml::write_oml(&raw, 2).unwrap();
let parsed = oml::read_oml(&text).unwrap();
let doc2 = Doc::from_raw(parsed).unwrap();
prop_assert!(doc.eq_doc(&doc2));
}
#[test]
fn json_round_trips_when_lossless(v in arb_object_value(3)) {
let doc = Doc::of(&v).unwrap();
prop_assume!(json::check_json(&doc).is_ok() && json::check_json(&doc).is_empty());
let text = json::write_json(&doc, None, true, None).unwrap();
let doc2 = json::read_json(&text).unwrap();
prop_assert!(doc.eq_doc(&doc2));
}
#[test]
fn yaml_round_trips_when_lossless(v in arb_object_value(3)) {
let doc = Doc::of(&v).unwrap();
prop_assume!(yaml::check_yaml(&doc).is_empty());
let text = yaml::write_yaml(&doc, true, None).unwrap();
let doc2 = yaml::read_yaml(&text).unwrap();
prop_assert!(doc.eq_doc(&doc2));
}
#[test]
fn toml_round_trips_when_lossless(v in arb_object_value(3)) {
let doc = Doc::of(&v).unwrap();
prop_assume!(toml::check_toml(&doc).is_empty());
let text = toml::write_toml(&doc, true, None).unwrap();
let doc2 = toml::read_toml(&text).unwrap();
prop_assert!(doc.eq_doc(&doc2));
}
#[test]
fn xml_round_trips_when_lossless(v in arb_single_root_xml_value(3)) {
let doc = Doc::of(&v).unwrap();
prop_assume!(xml::check_xml(&doc).is_empty());
let text = xml::write_xml(&doc, true, None).unwrap();
let doc2 = xml::read_xml(&text).unwrap();
prop_assert!(doc.eq_doc(&doc2));
}
}
fn arb_scalar_kind() -> impl Strategy<Value = schema::ScalarKind> {
proptest::sample::select(schema::ScalarKind::ALL.to_vec())
}
fn arb_schema(n_records: usize) -> impl Strategy<Value = Schema> {
let names: Vec<String> = (0..n_records).map(|i| format!("r{i}")).collect();
let record_strats: Vec<_> = (0..n_records)
.map(|i| {
let names = names.clone();
proptest::collection::vec(
(
arb_label(),
prop_oneof![
arb_scalar_kind()
.prop_map(|k| FieldType::Scalar(schema::Scalar::new(k, false))),
arb_scalar_kind()
.prop_map(|k| FieldType::Scalar(schema::Scalar::new(k, true))),
proptest::sample::select(
names[..=i.min(names.len().saturating_sub(1))].to_vec()
)
.prop_map(|n| FieldType::Ref(Ref::new(n))),
Just(FieldType::Any),
],
0usize..2,
proptest::option::of(1usize..3),
),
0..3,
)
})
.collect();
record_strats.prop_map(move |all_fields| {
let mut env = IndexMap::new();
for (i, fields) in all_fields.into_iter().enumerate() {
let mut seen = std::collections::HashSet::new();
let mut built = Vec::new();
for (label, ty, min, max_extra) in fields {
if !seen.insert(label.clone()) {
continue; }
let max = max_extra.map(|m| min + m);
if let Ok(f) = Field::new(label, ty, min, max) {
built.push(f);
}
}
env.insert(format!("r{i}"), Record::new(built).unwrap());
}
Schema::new(Ref::new("r0"), env).unwrap()
})
}
fn narrow_root_field(s: &Schema, field_idx: usize, choice: u8) -> Option<Schema> {
let root_name = s.root().name.clone();
let mut env = s.env().clone();
let root = env.get(&root_name)?.clone();
let fields = root.fields();
if fields.is_empty() {
return None;
}
let idx = field_idx % fields.len();
let f = &fields[idx];
let (new_min, new_max) = match choice % 3 {
0 if f.max.is_some_and(|m| m > f.min) => (f.min, f.max.map(|m| m - 1)),
1 if f.max.is_none_or(|m| f.min < m) => (f.min + 1, f.max),
2 if f.min == 0 => (0, Some(0)),
_ => return None,
};
let mut new_fields: Vec<Field> = fields.to_vec();
new_fields[idx] = Field::new(f.label.clone(), f.ty.clone(), new_min, new_max).ok()?;
env.insert(root_name.clone(), Record::new(new_fields).ok()?);
Schema::new(Ref::new(root_name), env).ok()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(*CASES))]
#[test]
fn compatible_with_is_reflexive(s in arb_schema(3)) {
prop_assert!(compatible_with(&s, &s));
}
#[test]
fn compatible_with_holds_for_narrowed_cardinality(
s in arb_schema(3),
field_idx in 0usize..8,
choice in 0u8..3,
) {
if let Some(narrowed) = narrow_root_field(&s, field_idx, choice) {
prop_assert!(compatible_with(&narrowed, &s));
}
}
#[test]
fn normalize_preserves_isomorphism(s in arb_schema(3)) {
let n = normalize(&s);
prop_assert!(is_isomorphic(&s, &n));
}
#[test]
fn prune_is_idempotent(s in arb_schema(3)) {
let once = prune(&s);
let twice = prune(&once);
prop_assert_eq!(once, twice);
}
#[test]
fn extract_only_keeps_requested_labels(s in arb_schema(3), keep_idx in proptest::collection::vec(0usize..4, 0..3)) {
let root = s.env().get(s.root().name.as_str()).unwrap();
let all_labels: Vec<&str> = root.fields().iter().map(|f| f.label.as_str()).collect();
let keep: Vec<&str> = keep_idx
.into_iter()
.filter_map(|i| all_labels.get(i).copied())
.collect();
if let Ok(extracted) = extract(&s, &keep) {
let root2 = extracted.env().get(extracted.root().name.as_str()).unwrap();
for f in root2.fields() {
prop_assert!(keep.contains(&f.label.as_str()));
}
}
}
}
static ORACLE_CASES: LazyLock<u32> = LazyLock::new(|| {
std::env::var("OMNIST_ORACLE_CASES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(20)
});
fn oracle_python() -> Option<String> {
std::env::var("OMNIST_ORACLE_PYTHON").ok()
}
fn extract_bool(json: &str, key: &str) -> bool {
let needle = format!("\"{key}\": ");
let start = json
.find(&needle)
.unwrap_or_else(|| panic!("oracle_check.py output missing {key:?}: {json}"))
+ needle.len();
json[start..].starts_with("true")
}
#[test]
fn cross_implementation_oracle_bounded_sample() {
let Some(python) = oracle_python() else {
eprintln!(
"skipping cross_implementation_oracle_bounded_sample: set \
OMNIST_ORACLE_PYTHON to a python3 executable with `omnist` \
installed (e.g. `~/dev/venvs/omnist/bin/python3`, after \
`pip install -e ~/dev/omnist`) to run the live-Python oracle. \
See .github/workflows/ci.yml's `fuzz` job for how CI wires \
this up."
);
return;
};
let script = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/oracle_check.py"
);
let mut runner = TestRunner::default();
let pair_strategy = (arb_schema(3), arb_schema(3));
let dir = std::env::temp_dir();
for i in 0..*ORACLE_CASES {
let tree = pair_strategy
.new_tree(&mut runner)
.expect("strategy generation should not fail");
let (a, b) = tree.current();
let a_path = dir.join(format!("omnist-oracle-{}-{}-a.osd", std::process::id(), i));
let b_path = dir.join(format!("omnist-oracle-{}-{}-b.osd", std::process::id(), i));
std::fs::write(&a_path, osd::to_osd(&a, None)).unwrap();
std::fs::write(&b_path, osd::to_osd(&b, None)).unwrap();
let output = std::process::Command::new(&python)
.arg(script)
.arg(&a_path)
.arg(&b_path)
.output()
.expect("failed to invoke OMNIST_ORACLE_PYTHON");
std::fs::remove_file(&a_path).ok();
std::fs::remove_file(&b_path).ok();
assert!(
output.status.success(),
"oracle_check.py failed (case {i}):\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let rust_compat = compatible_with(&a, &b);
let rust_empty_a = is_empty(&a);
let rust_np_empty_a = is_empty(&prune(&normalize(&a)));
let py_compat = extract_bool(&stdout, "compatible_a_b");
let py_empty_a = extract_bool(&stdout, "is_empty_a");
let py_np_empty_a = extract_bool(&stdout, "normalize_prune_is_empty_a");
assert_eq!(
rust_compat,
py_compat,
"compatible_with disagreement (case {i}):\na = {}\nb = {}",
osd::to_osd(&a, Some(2)),
osd::to_osd(&b, Some(2)),
);
assert_eq!(
rust_empty_a,
py_empty_a,
"is_empty disagreement (case {i}):\na = {}",
osd::to_osd(&a, Some(2)),
);
assert_eq!(
rust_np_empty_a,
py_np_empty_a,
"is_empty(prune(normalize(_))) disagreement (case {i}):\na = {}",
osd::to_osd(&a, Some(2)),
);
}
}