use assert_cmd::Command;
use hedl_cli::commands::{format, from_json, read_file, to_json, validate};
use hedl_core::parse;
use proptest::prelude::*;
use serial_test::serial;
use std::fs;
use tempfile::NamedTempFile;
fn create_temp_file(content: &str, suffix: &str) -> NamedTempFile {
let file = tempfile::Builder::new()
.suffix(suffix)
.tempfile()
.expect("Failed to create temp file");
fs::write(file.path(), content).expect("Failed to write temp file");
file
}
fn hedl_cmd() -> Command {
Command::new(assert_cmd::cargo::cargo_bin!("hedl"))
}
fn identifier() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-z_][a-z0-9_]{0,19}")
.expect("Failed to create identifier regex")
.prop_filter("Exclude double underscore prefix", |s| !s.starts_with("__"))
}
fn type_name() -> impl Strategy<Value = String> {
prop::string::string_regex("[A-Z][a-zA-Z0-9]{0,19}").expect("Failed to create type name regex")
}
fn hedl_string() -> impl Strategy<Value = String> {
prop::string::string_regex(r"[a-zA-Z0-9 .,!?()-]{0,100}")
.expect("Failed to create string regex")
}
fn hedl_int() -> impl Strategy<Value = i64> {
any::<i64>()
}
fn simple_hedl_document() -> impl Strategy<Value = String> {
(identifier(), hedl_string()).prop_map(|(key, value)| {
format!(
"%VERSION: 1.0\n---\n{}: \"{}\"",
key,
value.replace('\\', "\\\\").replace('"', "\\\"")
)
})
}
fn multi_field_document() -> impl Strategy<Value = String> {
prop::collection::vec((identifier(), hedl_string()), 1..10).prop_map(|fields| {
let mut doc = String::from("%VERSION: 1.0\n---\n");
let mut used_keys = std::collections::HashSet::new();
for (key, value) in fields {
let mut unique_key = key.clone();
let mut counter = 1;
while !used_keys.insert(unique_key.clone()) {
unique_key = format!("{key}_{counter}");
counter += 1;
}
doc.push_str(&format!(
"{}: \"{}\"\n",
unique_key,
value.replace('\\', "\\\\").replace('"', "\\\"")
));
}
doc
})
}
fn integer_document() -> impl Strategy<Value = String> {
prop::collection::vec((identifier(), hedl_int()), 1..10).prop_map(|fields| {
let mut doc = String::from("%VERSION: 1.0\n---\n");
let mut used_keys = std::collections::HashSet::new();
for (key, value) in fields {
let mut unique_key = key.clone();
let mut counter = 1;
while !used_keys.insert(unique_key.clone()) {
unique_key = format!("{key}_{counter}");
counter += 1;
}
doc.push_str(&format!("{unique_key}: {value}\n"));
}
doc
})
}
fn boolean_document() -> impl Strategy<Value = String> {
prop::collection::vec((identifier(), any::<bool>()), 1..10).prop_map(|fields| {
let mut doc = String::from("%VERSION: 1.0\n---\n");
let mut used_keys = std::collections::HashSet::new();
for (key, value) in fields {
let mut unique_key = key.clone();
let mut counter = 1;
while !used_keys.insert(unique_key.clone()) {
unique_key = format!("{key}_{counter}");
counter += 1;
}
doc.push_str(&format!("{unique_key}: {value}\n"));
}
doc
})
}
fn matrix_list_document() -> impl Strategy<Value = String> {
(
type_name(),
identifier(),
prop::collection::vec((identifier(), hedl_string()), 1..5),
)
.prop_map(|(type_name, list_name, rows)| {
let mut doc = format!(
"%V:2.0\n%NULL:~\n%QUOTE:\"\n%S:{type_name}:[id,name]\n---\n{list_name}:@{type_name}\n"
);
let mut used_ids = std::collections::HashSet::new();
for (id, name) in &rows {
let mut unique_id = id.clone();
let mut counter = 1;
while !used_ids.insert(unique_id.clone()) {
unique_id = format!("{id}_{counter}");
counter += 1;
}
doc.push_str(&format!(
" |{},\"{}\"\n",
unique_id,
name.replace('\\', "\\\\").replace('"', "\\\"")
));
}
doc
})
}
fn null_document() -> impl Strategy<Value = String> {
prop::collection::vec(identifier(), 1..10).prop_map(|fields| {
let mut doc = String::from("%VERSION: 1.0\n---\n");
let mut used_keys = std::collections::HashSet::new();
for key in fields {
let mut unique_key = key.clone();
let mut counter = 1;
while !used_keys.insert(unique_key.clone()) {
unique_key = format!("{key}_{counter}");
counter += 1;
}
doc.push_str(&format!("{unique_key}: ~\n"));
}
doc
})
}
fn mixed_document() -> impl Strategy<Value = String> {
(
prop::collection::vec(identifier(), 4..5),
hedl_string(),
hedl_int(),
any::<bool>(),
)
.prop_map(|(keys, v1, v2, v3)| {
let mut used_keys = std::collections::HashSet::new();
let mut unique_keys = Vec::new();
for key in keys {
let mut unique_key = key.clone();
let mut counter = 1;
while !used_keys.insert(unique_key.clone()) {
unique_key = format!("{key}_{counter}");
counter += 1;
}
unique_keys.push(unique_key);
}
while unique_keys.len() < 4 {
let mut key = format!("key{}", unique_keys.len());
while used_keys.contains(&key) {
key = format!("key{}_{}", unique_keys.len(), used_keys.len());
}
used_keys.insert(key.clone());
unique_keys.push(key);
}
format!(
"%VERSION: 1.0\n---\n{}: \"{}\"\n{}: {}\n{}: {}\n{}: ~\n",
unique_keys[0],
v1.replace('\\', "\\\\").replace('"', "\\\""),
unique_keys[1],
v2,
unique_keys[2],
v3,
unique_keys[3]
)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
#[serial]
fn prop_format_idempotent(doc in simple_hedl_document()) {
let file1 = create_temp_file(&doc, ".hedl");
let _file2 = create_temp_file(&doc, ".hedl");
let out1 = create_temp_file("", ".hedl");
let out2 = create_temp_file("", ".hedl");
let result1 = format(
file1.path().to_str().unwrap(),
Some(out1.path().to_str().unwrap()),
false,
true,
false,
);
prop_assert!(result1.is_ok(), "First format failed: {:?}", result1.err());
let result2 = format(
out1.path().to_str().unwrap(),
Some(out2.path().to_str().unwrap()),
false,
true,
false,
);
prop_assert!(result2.is_ok(), "Second format failed: {:?}", result2.err());
let formatted1 = fs::read_to_string(out1.path()).unwrap();
let formatted2 = fs::read_to_string(out2.path()).unwrap();
prop_assert_eq!(formatted1, formatted2, "Formatting is not idempotent");
}
#[test]
#[serial]
fn prop_format_accepts_valid_docs(doc in multi_field_document()) {
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
let result = format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
);
prop_assert!(result.is_ok(), "Format rejected valid document: {:?}", result.err());
}
#[test]
#[serial]
fn prop_formatted_output_is_parseable(doc in integer_document()) {
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
let formatted_content = fs::read_to_string(output.path()).unwrap();
let parse_result = parse(formatted_content.as_bytes());
prop_assert!(parse_result.is_ok(), "Formatted output is not parseable: {:?}", parse_result.err());
}
#[test]
#[serial]
fn prop_format_ditto_preserves_data(doc in boolean_document()) {
let file = create_temp_file(&doc, ".hedl");
let out_ditto = create_temp_file("", ".hedl");
let out_no_ditto = create_temp_file("", ".hedl");
format(
file.path().to_str().unwrap(),
Some(out_ditto.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
format(
file.path().to_str().unwrap(),
Some(out_no_ditto.path().to_str().unwrap()),
false,
false,
false,
).unwrap();
let ditto_content = fs::read_to_string(out_ditto.path()).unwrap();
let no_ditto_content = fs::read_to_string(out_no_ditto.path()).unwrap();
let ditto_doc = parse(ditto_content.as_bytes()).unwrap();
let no_ditto_doc = parse(no_ditto_content.as_bytes()).unwrap();
prop_assert_eq!(ditto_doc.root, no_ditto_doc.root, "Ditto and no-ditto produce different data");
}
#[test]
#[serial]
fn prop_format_with_counts_adds_hints(doc in matrix_list_document()) {
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
true, ).unwrap();
let formatted_content = fs::read_to_string(output.path()).unwrap();
prop_assert!(
formatted_content.contains("%C:") && formatted_content.contains(".total="),
"Formatted output missing %C: count directives"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
#[serial]
fn prop_validate_accepts_parseable(doc in simple_hedl_document()) {
let file = create_temp_file(&doc, ".hedl");
let result = validate(file.path().to_str().unwrap(), false, false);
prop_assert!(result.is_ok(), "Validation rejected parseable document: {:?}", result.err());
}
#[test]
#[serial]
fn prop_formatted_docs_validate(doc in multi_field_document()) {
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
let result = validate(output.path().to_str().unwrap(), false, false);
prop_assert!(result.is_ok(), "Formatted document failed validation: {:?}", result.err());
}
#[test]
#[serial]
fn prop_validation_is_consistent(doc in integer_document()) {
let file = create_temp_file(&doc, ".hedl");
let result1 = validate(file.path().to_str().unwrap(), false, false);
let result2 = validate(file.path().to_str().unwrap(), false, false);
prop_assert_eq!(
result1.is_ok(),
result2.is_ok(),
"Validation is not consistent across calls"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
#[serial]
fn prop_json_roundtrip_preserves_data(doc in simple_hedl_document()) {
let hedl_file = create_temp_file(&doc, ".hedl");
let json_file = create_temp_file("", ".json");
let hedl_output = create_temp_file("", ".hedl");
let to_json_result = to_json(
hedl_file.path().to_str().unwrap(),
Some(json_file.path().to_str().unwrap()),
false,
true,
);
prop_assert!(to_json_result.is_ok(), "HEDL to JSON conversion failed: {:?}", to_json_result.err());
let from_json_result = from_json(
json_file.path().to_str().unwrap(),
Some(hedl_output.path().to_str().unwrap()),
);
prop_assert!(from_json_result.is_ok(), "JSON to HEDL conversion failed: {:?}", from_json_result.err());
let original_content = fs::read_to_string(hedl_file.path()).unwrap();
let roundtrip_content = fs::read_to_string(hedl_output.path()).unwrap();
let original_doc = parse(original_content.as_bytes()).unwrap();
let roundtrip_doc = parse(roundtrip_content.as_bytes()).unwrap();
prop_assert_eq!(
original_doc.root,
roundtrip_doc.root,
"JSON round-trip did not preserve data"
);
}
#[test]
#[serial]
fn prop_json_conversion_produces_valid_json(doc in multi_field_document()) {
let hedl_file = create_temp_file(&doc, ".hedl");
let json_file = create_temp_file("", ".json");
to_json(
hedl_file.path().to_str().unwrap(),
Some(json_file.path().to_str().unwrap()),
false,
true,
).unwrap();
let json_content = fs::read_to_string(json_file.path()).unwrap();
let parse_result: Result<serde_json::Value, _> = serde_json::from_str(&json_content);
prop_assert!(parse_result.is_ok(), "Generated JSON is not valid: {:?}", parse_result.err());
}
#[test]
#[serial]
fn prop_json_pretty_vs_compact(doc in integer_document()) {
let hedl_file = create_temp_file(&doc, ".hedl");
let json_pretty = create_temp_file("", ".json");
let json_compact = create_temp_file("", ".json");
to_json(
hedl_file.path().to_str().unwrap(),
Some(json_pretty.path().to_str().unwrap()),
false,
true, ).unwrap();
to_json(
hedl_file.path().to_str().unwrap(),
Some(json_compact.path().to_str().unwrap()),
false,
false, ).unwrap();
let pretty_content = fs::read_to_string(json_pretty.path()).unwrap();
let compact_content = fs::read_to_string(json_compact.path()).unwrap();
let pretty_value: serde_json::Value = serde_json::from_str(&pretty_content).unwrap();
let compact_value: serde_json::Value = serde_json::from_str(&compact_content).unwrap();
prop_assert_eq!(
pretty_value,
compact_value,
"Pretty and compact JSON contain different data"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
#[serial]
fn prop_invalid_syntax_gives_error(key in identifier()) {
let invalid_doc = format!("%VERSION: 1.0\n---\n{key}:invalid");
let file = create_temp_file(&invalid_doc, ".hedl");
let result = validate(file.path().to_str().unwrap(), false, false);
prop_assert!(result.is_err(), "Validation should fail for invalid syntax");
}
#[test]
#[serial]
fn prop_missing_version_gives_error(doc in simple_hedl_document()) {
let no_version = doc.replace("%VERSION: 1.0\n", "");
let file = create_temp_file(&no_version, ".hedl");
let result = validate(file.path().to_str().unwrap(), false, false);
prop_assert!(result.is_err(), "Validation should fail for missing version");
}
}
struct EnvVarGuard {
name: &'static str,
original_value: Option<String>,
}
impl EnvVarGuard {
fn new(name: &'static str, value: &str) -> Self {
let original_value = std::env::var(name).ok();
std::env::set_var(name, value);
Self {
name,
original_value,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original_value {
Some(val) => std::env::set_var(self.name, val),
None => std::env::remove_var(self.name),
}
}
}
#[test]
#[serial]
fn test_file_size_limit_respected() {
let small_doc = format!("%VERSION: 1.0\n---\n{}\n", "a: 1\n".repeat(50));
let file = create_temp_file(&small_doc, ".hedl");
let _guard = EnvVarGuard::new("HEDL_MAX_FILE_SIZE", "1024");
let result = read_file(file.path().to_str().unwrap());
assert!(result.is_ok(), "Small file should be readable");
}
#[test]
#[serial]
fn test_oversized_file_rejected() {
let large_doc = format!("%VERSION: 1.0\n---\n{}\n", "a: 1\n".repeat(1000));
let file = create_temp_file(&large_doc, ".hedl");
let _guard = EnvVarGuard::new("HEDL_MAX_FILE_SIZE", "100");
let result = read_file(file.path().to_str().unwrap());
assert!(result.is_err(), "Oversized file should be rejected");
assert!(
result.unwrap_err().to_string().contains("too large"),
"Error message should mention file size"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
#[serial]
fn prop_empty_document_is_valid(_seed in any::<u64>()) {
let doc = "%VERSION: 1.0\n---\n";
let file = create_temp_file(doc, ".hedl");
let result = validate(file.path().to_str().unwrap(), false, false);
prop_assert!(result.is_ok(), "Empty document should be valid");
}
#[test]
#[serial]
fn prop_null_only_document_is_valid(doc in null_document()) {
let file = create_temp_file(&doc, ".hedl");
let result = validate(file.path().to_str().unwrap(), false, false);
prop_assert!(result.is_ok(), "Null-only document should be valid: {:?}", result.err());
}
#[test]
#[serial]
fn prop_mixed_type_document_is_valid(doc in mixed_document()) {
let file = create_temp_file(&doc, ".hedl");
let result = validate(file.path().to_str().unwrap(), false, false);
prop_assert!(result.is_ok(), "Mixed-type document should be valid: {:?}", result.err());
}
}
#[test]
#[serial]
fn test_format_preserves_data_for_all_types() {
use hedl_cli::commands::format;
let test_docs = [
"%VERSION: 1.0\n---\nname: \"test\"\n",
"%VERSION: 1.0\n---\ncount: 42\n",
"%VERSION: 1.0\n---\nflag: true\n",
"%VERSION: 1.0\n---\nvalue: ~\n",
"%VERSION: 1.0\n---\nvalue: 2.5\n",
];
for (i, doc) in test_docs.iter().enumerate() {
let file = create_temp_file(doc, ".hedl");
let output = create_temp_file("", ".hedl");
let result = format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
);
assert!(
result.is_ok(),
"Format failed for test case {}: {:?}",
i,
result.err()
);
let formatted_content = fs::read_to_string(output.path()).unwrap();
let parse_result = parse(formatted_content.as_bytes());
assert!(
parse_result.is_ok(),
"Formatted output for test case {} is not parseable: {:?}",
i,
parse_result.err()
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
#[serial]
fn prop_cli_format_matches_library(doc in simple_hedl_document()) {
let file = create_temp_file(&doc, ".hedl");
let cli_output = create_temp_file("", ".hedl");
let lib_output = create_temp_file("", ".hedl");
hedl_cmd()
.arg("format")
.arg(file.path())
.arg("-o")
.arg(cli_output.path())
.assert()
.success();
format(
file.path().to_str().unwrap(),
Some(lib_output.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
let cli_content = fs::read_to_string(cli_output.path()).unwrap();
let lib_content = fs::read_to_string(lib_output.path()).unwrap();
prop_assert_eq!(cli_content, lib_content, "CLI and library format produce different results");
}
#[test]
#[serial]
fn prop_cli_validate_matches_library(doc in multi_field_document()) {
let file = create_temp_file(&doc, ".hedl");
let cli_result = hedl_cmd()
.arg("validate")
.arg(file.path())
.assert();
let lib_result = validate(file.path().to_str().unwrap(), false, false);
prop_assert_eq!(
cli_result.get_output().status.success(),
lib_result.is_ok(),
"CLI and library validation disagree"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
#[serial]
fn invariant_parse_canonicalize_parse(doc in simple_hedl_document()) {
let file = create_temp_file(&doc, ".hedl");
let formatted = create_temp_file("", ".hedl");
let content1 = fs::read_to_string(file.path()).unwrap();
let doc1 = parse(content1.as_bytes()).unwrap();
format(
file.path().to_str().unwrap(),
Some(formatted.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
let content2 = fs::read_to_string(formatted.path()).unwrap();
let doc2 = parse(content2.as_bytes()).unwrap();
prop_assert_eq!(doc1.root, doc2.root, "Parse-canonicalize-parse changed data");
}
#[test]
#[serial]
fn invariant_format_preserves_version(doc in simple_hedl_document()) {
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
let original = parse(doc.as_bytes()).unwrap();
format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
let formatted_content = fs::read_to_string(output.path()).unwrap();
let formatted = parse(formatted_content.as_bytes()).unwrap();
prop_assert_eq!(
original.version,
formatted.version,
"Formatting changed document version"
);
}
#[test]
#[serial]
fn invariant_format_preserves_item_count(doc in multi_field_document()) {
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
let original = parse(doc.as_bytes()).unwrap();
let original_count = original.root.len();
format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
).unwrap();
let formatted_content = fs::read_to_string(output.path()).unwrap();
let formatted = parse(formatted_content.as_bytes()).unwrap();
let formatted_count = formatted.root.len();
prop_assert_eq!(
original_count,
formatted_count,
"Formatting changed number of root items"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
#[serial]
fn prop_format_performance_scales(field_count in 1usize..100) {
let mut doc = String::from("%VERSION: 1.0\n---\n");
for i in 0..field_count {
doc.push_str(&format!("field{i}: {i}\n"));
}
let file = create_temp_file(&doc, ".hedl");
let output = create_temp_file("", ".hedl");
let start = std::time::Instant::now();
let result = format(
file.path().to_str().unwrap(),
Some(output.path().to_str().unwrap()),
false,
true,
false,
);
let duration = start.elapsed();
prop_assert!(result.is_ok(), "Format failed for {} fields", field_count);
prop_assert!(
duration.as_secs() < 1,
"Format took too long: {:?} for {} fields",
duration,
field_count
);
}
}