use proptest::prelude::*;
use std::collections::BTreeMap;
use premortem::{
ConfigError, ConfigErrors, ConfigValidation, ConfigValue, ConfigValues, SourceLocation,
Validate, Validator, Value,
};
use stillwater::{Semigroup, Validation};
fn arb_value() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
any::<i64>().prop_map(Value::Integer),
any::<f64>()
.prop_filter("finite", |f| f.is_finite())
.prop_map(Value::Float),
"[a-zA-Z0-9_\\-]{0,50}".prop_map(Value::String),
];
leaf.prop_recursive(
3, 64, 10, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..5).prop_map(Value::Array),
prop::collection::btree_map("[a-z_]{1,10}", inner, 0..5).prop_map(Value::Table),
]
},
)
}
fn arb_source_location() -> impl Strategy<Value = SourceLocation> {
(
prop_oneof!["[a-z_]{1,20}\\.toml", "env:[A-Z_]{1,20}", "[a-z_/]{1,30}",],
proptest::option::of(1u32..1000),
proptest::option::of(1u32..200),
)
.prop_map(|(source, line, column)| {
let mut loc = SourceLocation::new(source);
if let Some(l) = line {
loc = loc.with_line(l);
}
if let Some(c) = column {
loc = loc.with_column(c);
}
loc
})
}
fn arb_source_error_kind() -> impl Strategy<Value = premortem::SourceErrorKind> {
use premortem::SourceErrorKind;
prop_oneof![
"[a-z_/]{1,30}\\.toml".prop_map(|path| SourceErrorKind::NotFound { path }),
"[a-zA-Z0-9 ]{1,30}".prop_map(|message| SourceErrorKind::IoError { message }),
(
"[a-zA-Z0-9 ]{1,30}",
proptest::option::of(1u32..1000),
proptest::option::of(1u32..200),
)
.prop_map(|(message, line, column)| SourceErrorKind::ParseError {
message,
line,
column,
}),
"[a-zA-Z0-9 ]{1,30}".prop_map(|message| SourceErrorKind::ConnectionError { message }),
"[a-zA-Z0-9 ]{1,30}".prop_map(|message| SourceErrorKind::Other { message }),
]
}
fn arb_config_error() -> impl Strategy<Value = ConfigError> {
prop_oneof![
("[a-z_]{1,20}\\.toml", arb_source_error_kind())
.prop_map(|(source_name, kind)| { ConfigError::SourceError { source_name, kind } }),
(
"[a-z][a-z.]{0,20}",
arb_source_location(),
prop_oneof!["integer", "string", "boolean", "float", "array", "table"],
"[a-zA-Z0-9_\\-]{1,20}",
"[a-zA-Z0-9 ]{1,50}",
)
.prop_map(
|(path, source_location, expected_type, actual_value, message)| {
ConfigError::ParseError {
path,
source_location,
expected_type: expected_type.to_string(),
actual_value,
message,
}
},
),
(
"[a-z][a-z.]{0,20}",
proptest::option::of(arb_source_location()),
prop::collection::vec("[a-z_]{1,15}\\.toml", 1..3),
)
.prop_map(|(path, source_location, searched_sources)| {
ConfigError::MissingField {
path,
source_location,
searched_sources,
}
}),
(
"[a-z][a-z.]{0,20}",
proptest::option::of(arb_source_location()),
proptest::option::of("[a-zA-Z0-9_\\-]{1,20}"),
"[a-zA-Z0-9 ]{1,50}",
)
.prop_map(|(path, source_location, value, message)| {
ConfigError::ValidationError {
path,
source_location,
value,
message,
}
}),
(
prop::collection::vec("[a-z]{1,10}", 2..4),
"[a-zA-Z0-9 ]{1,50}"
)
.prop_map(|(paths, msg)| ConfigError::CrossFieldError {
paths,
message: msg,
}),
(
"[a-z][a-z.]{0,20}",
arb_source_location(),
proptest::option::of("[a-z]{1,15}"),
)
.prop_map(
|(path, source_location, did_you_mean)| ConfigError::UnknownField {
path,
source_location,
did_you_mean,
}
),
Just(ConfigError::NoSources),
]
}
fn arb_config_errors() -> impl Strategy<Value = ConfigErrors> {
prop::collection::vec(arb_config_error(), 1..5)
.prop_map(|errs| ConfigErrors::from_vec(errs).expect("non-empty vec"))
}
fn arb_config_value() -> impl Strategy<Value = ConfigValue> {
(arb_value(), arb_source_location()).prop_map(|(value, source)| ConfigValue::new(value, source))
}
fn arb_config_values() -> impl Strategy<Value = ConfigValues> {
prop::collection::btree_map("[a-z][a-z.]{0,15}", arb_config_value(), 0..10).prop_map(|map| {
let mut values = ConfigValues::empty();
for (path, cv) in map {
values.insert(path, cv);
}
values
})
}
fn arb_path() -> impl Strategy<Value = String> {
prop_oneof![
"[a-z]{1,10}", "[a-z]{1,10}\\.[a-z]{1,10}", "[a-z]{1,10}\\.[a-z]{1,10}\\.[a-z]{1,10}", ]
}
fn arb_prefix() -> impl Strategy<Value = String> {
"[a-z]{1,10}"
}
mod semigroup_laws {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn config_errors_associativity(
a in arb_config_errors(),
b in arb_config_errors(),
c in arb_config_errors(),
) {
let left = a.clone().combine(b.clone()).combine(c.clone());
let right = a.combine(b.combine(c));
prop_assert_eq!(left.len(), right.len());
}
#[test]
fn config_errors_preserves_count(
a in arb_config_errors(),
b in arb_config_errors(),
) {
let a_len = a.len();
let b_len = b.len();
let combined = a.combine(b);
prop_assert_eq!(combined.len(), a_len + b_len);
}
}
}
mod value_roundtrips {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn value_json_structural_preservation(value in arb_value()) {
let json = value_to_json(&value);
let json_str = serde_json::to_string(&json);
prop_assert!(json_str.is_ok());
}
#[test]
fn value_type_name_valid(value in arb_value()) {
let type_name = value.type_name();
prop_assert!(!type_name.is_empty());
prop_assert!(["null", "boolean", "integer", "float", "string", "array", "table"]
.contains(&type_name));
}
#[test]
fn value_is_null_correct(value in arb_value()) {
let is_null = value.is_null();
let is_null_variant = matches!(value, Value::Null);
prop_assert_eq!(is_null, is_null_variant);
}
}
fn value_to_json(value: &Value) -> serde_json::Value {
match value {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Integer(i) => serde_json::Value::Number((*i).into()),
Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::String(s) => serde_json::Value::String(s.clone()),
Value::Array(arr) => serde_json::Value::Array(arr.iter().map(value_to_json).collect()),
Value::Table(table) => {
let map: serde_json::Map<String, serde_json::Value> = table
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}
}
mod merge_properties {
use super::*;
use premortem::merge_config_values;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn merge_associative_keys(
a in arb_config_values(),
b in arb_config_values(),
c in arb_config_values(),
) {
let left = merge_config_values(vec![
merge_config_values(vec![a.clone(), b.clone()]),
c.clone(),
]);
let right = merge_config_values(vec![
a,
merge_config_values(vec![b, c]),
]);
let left_paths: Vec<_> = left.paths().collect();
let right_paths: Vec<_> = right.paths().collect();
prop_assert_eq!(left_paths, right_paths);
}
#[test]
fn later_source_wins(
a in arb_config_values(),
b in arb_config_values(),
) {
let merged = merge_config_values(vec![a, b.clone()]);
for path in b.paths() {
let merged_value = merged.get(path);
let b_value = b.get(path);
prop_assert!(
merged_value.is_some(),
"Path '{}' from later source should exist in merged result",
path
);
let merged_cv = merged_value.unwrap();
let b_cv = b_value.unwrap();
prop_assert_eq!(
&merged_cv.value,
&b_cv.value,
"Path '{}': merged value {:?} should equal later source value {:?}",
path,
&merged_cv.value,
&b_cv.value
);
}
}
#[test]
fn merge_preserves_all_keys(
a in arb_config_values(),
b in arb_config_values(),
) {
let a_paths: std::collections::HashSet<_> = a.paths().cloned().collect();
let b_paths: std::collections::HashSet<_> = b.paths().cloned().collect();
let expected_paths: std::collections::HashSet<_> = a_paths.union(&b_paths).cloned().collect();
let merged = merge_config_values(vec![a, b]);
let merged_paths: std::collections::HashSet<_> = merged.paths().cloned().collect();
prop_assert_eq!(expected_paths, merged_paths);
}
#[test]
fn empty_merge_identity(a in arb_config_values()) {
let merged_left = merge_config_values(vec![ConfigValues::empty(), a.clone()]);
let merged_right = merge_config_values(vec![a.clone(), ConfigValues::empty()]);
prop_assert_eq!(a.len(), merged_left.len());
prop_assert_eq!(a.len(), merged_right.len());
}
}
}
mod validator_properties {
use super::*;
use premortem::validate::{validate_field, validators::*};
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn non_empty_deterministic(s in ".*") {
let v1 = NonEmpty.validate(&s, "test");
let v2 = NonEmpty.validate(&s, "test");
prop_assert_eq!(v1.is_success(), v2.is_success());
}
#[test]
fn non_empty_correct(s in ".*") {
let result = NonEmpty.validate(&s, "test");
let expected_success = !s.is_empty();
prop_assert_eq!(result.is_success(), expected_success);
}
#[test]
fn range_accepts_in_bounds(
min in -1000i64..1000,
max in -1000i64..1000,
val in -2000i64..2000,
) {
let (min, max) = if min <= max { (min, max) } else { (max, min) };
let validator = Range(min..=max);
let result = validator.validate(&val, "test");
let should_succeed = val >= min && val <= max;
prop_assert_eq!(result.is_success(), should_succeed);
}
#[test]
fn positive_correct(val in any::<i32>()) {
let result = Positive.validate(&val, "test");
prop_assert_eq!(result.is_success(), val > 0);
}
#[test]
fn negative_correct(val in any::<i32>()) {
let result = Negative.validate(&val, "test");
prop_assert_eq!(result.is_success(), val < 0);
}
#[test]
fn non_zero_correct(val in any::<i32>()) {
let result = NonZero.validate(&val, "test");
prop_assert_eq!(result.is_success(), val != 0);
}
#[test]
fn min_length_correct(s in ".{0,100}", min_len in 0usize..50) {
let result = MinLength(min_len).validate(&s, "test");
prop_assert_eq!(result.is_success(), s.len() >= min_len);
}
#[test]
fn max_length_correct(s in ".{0,100}", max_len in 0usize..100) {
let result = MaxLength(max_len).validate(&s, "test");
prop_assert_eq!(result.is_success(), s.len() <= max_len);
}
#[test]
fn validators_accumulate_errors(s in ".{0,5}") {
let result = validate_field(
&s,
"field",
&[&NonEmpty, &MinLength(10)],
);
if s.is_empty() {
if let Validation::Failure(errors) = result {
prop_assert_eq!(errors.len(), 2);
}
} else if s.len() < 10 {
if let Validation::Failure(errors) = result {
prop_assert_eq!(errors.len(), 1);
}
} else {
prop_assert!(result.is_success());
}
}
}
}
mod path_properties {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn path_prefix_prepends(
prefix in arb_prefix(),
path in arb_path(),
) {
let err = ConfigError::ValidationError {
path: path.clone(),
source_location: None,
value: None,
message: "test".into(),
};
let prefixed = err.with_path_prefix(&prefix);
if let Some(prefixed_path) = prefixed.path() {
prop_assert!(prefixed_path.starts_with(&prefix));
prop_assert!(prefixed_path.contains(&path));
} else {
prop_assert!(false, "Expected path after prefixing");
}
}
#[test]
fn path_prefix_dot_separator(
prefix in arb_prefix(),
path in "[a-z]{1,10}", ) {
let err = ConfigError::ValidationError {
path: path.clone(),
source_location: None,
value: None,
message: "test".into(),
};
let prefixed = err.with_path_prefix(&prefix);
if let Some(prefixed_path) = prefixed.path() {
let expected = format!("{}.{}", prefix, path);
prop_assert_eq!(prefixed_path, &expected);
}
}
#[test]
fn array_index_no_dot(
prefix in arb_prefix(),
index in 0usize..100,
) {
let path = format!("[{}]", index);
let err = ConfigError::ValidationError {
path,
source_location: None,
value: None,
message: "test".into(),
};
let prefixed = err.with_path_prefix(&prefix);
if let Some(prefixed_path) = prefixed.path() {
let expected = format!("{}[{}]", prefix, index);
prop_assert_eq!(prefixed_path, &expected);
}
}
#[test]
fn empty_path_becomes_prefix(prefix in arb_prefix()) {
let err = ConfigError::ValidationError {
path: String::new(),
source_location: None,
value: None,
message: "test".into(),
};
let prefixed = err.with_path_prefix(&prefix);
if let Some(prefixed_path) = prefixed.path() {
prop_assert_eq!(prefixed_path, &prefix);
}
}
#[test]
fn errors_prefix_applies_to_all(
prefix in arb_prefix(),
errors in arb_config_errors(),
) {
let original_len = errors.len();
let prefixed = errors.with_path_prefix(&prefix);
prop_assert_eq!(prefixed.len(), original_len);
for err in prefixed.iter() {
if let Some(path) = err.path() {
prop_assert!(
path.starts_with(&prefix),
"Path '{}' should start with prefix '{}'",
path,
prefix
);
}
}
}
}
}
mod error_properties {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn config_errors_always_nonempty(errors in arb_config_errors()) {
prop_assert!(!errors.is_empty());
}
#[test]
fn single_creates_one_error(error in arb_config_error()) {
let errors = ConfigErrors::single(error);
prop_assert_eq!(errors.len(), 1);
}
#[test]
fn from_vec_empty_returns_none(_dummy in any::<bool>()) {
let empty: Vec<ConfigError> = vec![];
prop_assert!(ConfigErrors::from_vec(empty).is_none());
}
#[test]
fn from_vec_nonempty_returns_some(errors in prop::collection::vec(arb_config_error(), 1..5)) {
let len = errors.len();
let result = ConfigErrors::from_vec(errors);
prop_assert!(result.is_some());
prop_assert_eq!(result.unwrap().len(), len);
}
#[test]
fn source_location_display_format(loc in arb_source_location()) {
let display = format!("{}", loc);
prop_assert!(display.contains(&loc.source));
if let Some(line) = loc.line {
let line_str = format!(":{}", line);
prop_assert!(display.contains(&line_str));
}
if loc.column.is_some() && loc.line.is_some() {
let col = loc.column.unwrap();
let col_str = format!(":{}", col);
prop_assert!(display.contains(&col_str));
}
}
#[test]
fn with_context_adds_to_validation_error(
path in arb_path(),
message in "[a-zA-Z0-9 ]{1,30}",
context in "[a-zA-Z0-9 ]{1,30}",
) {
let err = ConfigError::ValidationError {
path,
source_location: None,
value: None,
message: message.clone(),
};
let with_ctx = err.with_context(&context);
if let ConfigError::ValidationError { message: new_msg, .. } = with_ctx {
prop_assert!(new_msg.contains(&context));
prop_assert!(new_msg.contains(&message));
} else {
prop_assert!(false, "Expected ValidationError");
}
}
}
}
mod env_path_properties {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn env_location_format(var_name in "[A-Z][A-Z0-9_]{0,20}") {
let loc = SourceLocation::env(&var_name);
prop_assert!(loc.source.starts_with("env:"));
prop_assert!(loc.source.contains(&var_name));
prop_assert_eq!(loc.source, format!("env:{}", var_name));
}
#[test]
fn file_location_preserves_components(
path in "[a-z/]{1,30}\\.toml",
line in proptest::option::of(1u32..1000),
column in proptest::option::of(1u32..200),
) {
let loc = SourceLocation::file(&path, line, column);
prop_assert_eq!(loc.source, path);
prop_assert_eq!(loc.line, line);
prop_assert_eq!(loc.column, column);
}
}
}
mod validation_properties {
use super::*;
use premortem::ConfigValidationExt;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn fail_with_creates_failure(error in arb_config_error()) {
let result: ConfigValidation<()> = ConfigValidation::fail_with(error);
prop_assert!(result.is_failure());
if let Validation::Failure(errors) = result {
prop_assert_eq!(errors.len(), 1);
}
}
#[test]
fn unit_validate_always_succeeds(_dummy in any::<bool>()) {
let result = ().validate();
prop_assert!(result.is_success());
}
#[test]
fn option_validate_behavior(opt in proptest::option::of(any::<String>())) {
let result = opt.validate();
prop_assert!(result.is_success());
}
#[test]
fn empty_vec_validate_succeeds(_dummy in any::<bool>()) {
let v: Vec<String> = vec![];
let result = v.validate();
prop_assert!(result.is_success());
}
}
}
mod value_path_properties {
use super::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn get_empty_path_returns_none(value in arb_value()) {
let result = value.get_path("");
prop_assert!(result.is_none(), "Empty path should return None, got {:?}", result);
}
#[test]
fn get_path_table_access(
key in "[a-z]{1,10}",
inner_value in arb_value(),
) {
let mut table = BTreeMap::new();
table.insert(key.clone(), inner_value.clone());
let value = Value::Table(table);
let result = value.get_path(&key);
prop_assert_eq!(result, Some(&inner_value));
}
#[test]
fn get_path_non_table_returns_none(
path in "[a-z]{1,10}",
value in prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
any::<i64>().prop_map(Value::Integer),
any::<f64>().prop_filter("finite", |f| f.is_finite()).prop_map(Value::Float),
"[a-zA-Z0-9_]{0,20}".prop_map(Value::String),
prop::collection::vec(Just(Value::Null), 0..3).prop_map(Value::Array),
],
) {
if !matches!(value, Value::Table(_)) {
let result = value.get_path(&path);
prop_assert!(result.is_none());
}
}
}
}