hegeltest 0.14.17

Property-based testing for Rust, built on Hypothesis
Documentation
// Collection schema interpreters: list, dict, tuple, one_of, sampled_from.

use crate::cbor_utils::{as_bool, as_u64, map_get};
use crate::native::core::{EngineError, ManyState, NativeTestCase};
use ciborium::Value;

use super::{cbor_to_i128, interpret_schema, many_more, many_reject, require};

pub(super) fn interpret_tuple(
    ntc: &mut NativeTestCase,
    schema: &Value,
) -> Result<Value, EngineError> {
    let elements = match require(schema, "elements")? {
        Value::Array(arr) => arr,
        other => {
            return Err(EngineError::InvalidArgument(format!(
                "tuple schema \"elements\" must be an array, got {other:?}"
            )));
        }
    };
    let mut results = Vec::with_capacity(elements.len());
    for element_schema in elements {
        results.push(interpret_schema(ntc, element_schema)?);
    }
    Ok(Value::Array(results))
}

pub(super) fn interpret_one_of(
    ntc: &mut NativeTestCase,
    schema: &Value,
) -> Result<Value, EngineError> {
    let generators = match require(schema, "generators")? {
        Value::Array(arr) if !arr.is_empty() => arr,
        _ => {
            return Err(EngineError::InvalidArgument(
                "one_of schema \"generators\" must be a non-empty array".to_string(),
            ));
        }
    };
    let idx = ntc.draw_integer(0, generators.len() as i128 - 1)?;
    let value = interpret_schema(ntc, &generators[idx as usize])?;
    Ok(Value::Array(vec![
        Value::Integer((idx as i64).into()),
        value,
    ]))
}

pub(super) fn interpret_sampled_from(
    ntc: &mut NativeTestCase,
    schema: &Value,
) -> Result<Value, EngineError> {
    let values = match require(schema, "values")? {
        Value::Array(arr) if !arr.is_empty() => arr,
        _ => {
            return Err(EngineError::InvalidArgument(
                "sampled_from schema \"values\" must be a non-empty array".to_string(),
            ));
        }
    };
    let idx = ntc.draw_integer(0, values.len() as i128 - 1)?;
    Ok(encode_schema_value(&values[idx as usize]))
}

pub(super) fn interpret_list(
    ntc: &mut NativeTestCase,
    schema: &Value,
) -> Result<Value, EngineError> {
    let element_schema = require(schema, "elements")?;
    let min_size = map_get(schema, "min_size").and_then(as_u64).unwrap_or(0) as usize;
    let max_size = map_get(schema, "max_size")
        .and_then(as_u64)
        .map(|n| n as usize);
    let unique = map_get(schema, "unique").and_then(as_bool).unwrap_or(false);

    if unique {
        if let Some((min_val, max_val)) = bounded_integer_range(element_schema) {
            let range_size = (max_val - min_val + 1) as usize;
            return interpret_unique_integer_list(ntc, min_size, max_size, min_val, range_size);
        }
    }

    let mut state = ManyState::new(min_size, max_size);
    let mut results: Vec<Value> = Vec::new();

    loop {
        if !many_more(ntc, &mut state)? {
            break;
        }
        let element = interpret_schema(ntc, element_schema)?;
        if unique && results.iter().any(|existing| existing == &element) {
            many_reject(ntc, &mut state)?;
            continue;
        }
        results.push(element);
    }

    Ok(Value::Array(results))
}

/// Pool-based unique list generation for bounded integer ranges.
/// Port of Hypothesis's UniqueSampledListStrategy: draw indices into a
/// shrinking pool of remaining values, avoiding the coupon-collector problem.
fn interpret_unique_integer_list(
    ntc: &mut NativeTestCase,
    min_size: usize,
    max_size: Option<usize>,
    min_val: i128,
    range_size: usize,
) -> Result<Value, EngineError> {
    let effective_max = max_size.map_or(range_size, |m| m.min(range_size));
    let mut state = ManyState::new(min_size, Some(effective_max));
    let mut remaining: Vec<i128> = (min_val..min_val + range_size as i128).collect();
    let mut results = Vec::new();

    loop {
        if remaining.is_empty() || !many_more(ntc, &mut state)? {
            break;
        }
        let j = ntc.draw_integer(0, remaining.len() as i128 - 1)? as usize;
        let value = remaining.remove(j);
        results.push(Value::Integer((value as i64).into()));
    }

    Ok(Value::Array(results))
}

fn bounded_integer_range(schema: &Value) -> Option<(i128, i128)> {
    use crate::cbor_utils::as_text;
    let schema_type = map_get(schema, "type").and_then(as_text)?;
    if schema_type != "integer" {
        return None;
    }
    // Treat unparseable bounds as "not a bounded range" and fall back to the
    // generic list path, which surfaces the real `InvalidArgument` when it
    // interprets the element schema.
    let min_val = cbor_to_i128(map_get(schema, "min_value")?).ok()?;
    let max_val = cbor_to_i128(map_get(schema, "max_value")?).ok()?;
    if !(1..=10_000).contains(&(max_val - min_val + 1)) {
        return None;
    }
    Some((min_val, max_val))
}

pub(super) fn interpret_dict(
    ntc: &mut NativeTestCase,
    schema: &Value,
) -> Result<Value, EngineError> {
    let key_schema = require(schema, "keys")?;
    let val_schema = require(schema, "values")?;
    let min_size = map_get(schema, "min_size").and_then(as_u64).unwrap_or(0) as usize;
    let max_size = map_get(schema, "max_size")
        .and_then(as_u64)
        .map(|n| n as usize);

    let mut state = ManyState::new(min_size, max_size);
    let mut pairs: Vec<Value> = Vec::new();
    let mut keys: Vec<Value> = Vec::new();

    loop {
        if !many_more(ntc, &mut state)? {
            break;
        }
        let key = interpret_schema(ntc, key_schema)?;
        if keys.iter().any(|existing| existing == &key) {
            many_reject(ntc, &mut state)?;
            continue;
        }
        let value = interpret_schema(ntc, val_schema)?;
        keys.push(key.clone());
        pairs.push(Value::Array(vec![key, value]));
    }

    Ok(Value::Array(pairs))
}

/// Encode a schema value for transport back to the generator.
///
/// Mirrors hegel-core's `_encode_value`: text strings are wrapped in
/// CBOR tag 91 (HEGEL_STRING_TAG) so they can be deserialized by `HegelValue`.
fn encode_schema_value(value: &Value) -> Value {
    match value {
        Value::Text(s) => Value::Tag(91, Box::new(Value::Bytes(s.as_bytes().to_vec()))),
        other => other.clone(),
    }
}

#[cfg(test)]
#[path = "../../../tests/embedded/native/schema/collections_tests.rs"]
mod tests;