qubit-validator 0.1.2

Type-safe validation traits and registries for Qubit applications
Documentation
// =============================================================================
//    Copyright (c) 2025 - 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================

use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;

use qubit_validator::ArgumentReader;
use qubit_validator::BindError;
use qubit_validator::BoundValidationContext;
use qubit_validator::DependencySpec;
use qubit_validator::ExecutionError;
use qubit_validator::ExecutionErrorKind;
use qubit_validator::InputType;
use qubit_validator::PreparedOutcome;
use qubit_validator::PreparedValidator;
use qubit_validator::ValidationOutcome;
use qubit_validator::ValidationPath;
use qubit_validator::ValidationValue;
use qubit_validator::Validator;
use qubit_validator::ValidatorDescriptor;
use qubit_validator::ValidatorId;
use qubit_validator::ValidatorSignature;
use qubit_validator::ViolationCode;
use qubit_validator::ViolationDraft;
use qubit_validator::prepare_contextual_text_validator;
use qubit_validator::prepare_contextual_typed_validator;
use qubit_validator::prepare_text_with_context;
use qubit_validator::prepare_typed_with_context;
use thiserror::Error;

#[derive(Debug, Error)]
#[error("text does not match the dependency pair")]
struct DependencyPairMismatch;

struct MatchesDependencyPair;

impl<'a> Validator<str, BoundValidationContext<'a>> for MatchesDependencyPair {
    type Error = DependencyPairMismatch;

    fn validate(&self, value: &str, context: &BoundValidationContext<'a>) -> Result<(), Self::Error> {
        let first = context.text(0).map_err(|_| DependencyPairMismatch)?;
        let second = context.text(1).map_err(|_| DependencyPairMismatch)?;
        if value == format!("{first}:{second}") {
            Ok(())
        } else {
            Err(DependencyPairMismatch)
        }
    }
}

static TEXT_DEPENDENCIES: &[DependencySpec] = &[
    DependencySpec::new("first", InputType::Text, false),
    DependencySpec::new("second", InputType::Text, false),
];

fn prepare_dependency_pair(_reader: &mut ArgumentReader<'_>) -> Result<Arc<dyn PreparedValidator>, BindError> {
    Ok(prepare_contextual_text_validator(
        TEXT_DEPENDENCIES,
        MatchesDependencyPair,
        |_| ViolationDraft::new(ViolationCode::new("text.dependency_mismatch")),
    ))
}

static TEXT_SIGNATURES: &[ValidatorSignature] = &[ValidatorSignature::new(
    InputType::Text,
    TEXT_DEPENDENCIES,
    prepare_dependency_pair,
)];
static TEXT_DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(TEXT_SIGNATURES);

#[derive(Debug, Error)]
#[error("value is below its optional minimum")]
struct BelowMinimum;

struct OptionalMinimum;

impl<'a> Validator<u32, BoundValidationContext<'a>> for OptionalMinimum {
    type Error = BelowMinimum;

    fn validate(&self, value: &u32, context: &BoundValidationContext<'a>) -> Result<(), Self::Error> {
        let Some(minimum) = context.optional_typed::<u32>(0).map_err(|_| BelowMinimum)? else {
            return Ok(());
        };
        if value >= minimum { Ok(()) } else { Err(BelowMinimum) }
    }
}

static OPTIONAL_DEPENDENCIES: &[DependencySpec] = &[DependencySpec::new("minimum", InputType::of::<u32>(), true)];

fn prepare_optional_minimum(_reader: &mut ArgumentReader<'_>) -> Result<Arc<dyn PreparedValidator>, BindError> {
    Ok(prepare_contextual_typed_validator::<u32, _, _, _>(
        OPTIONAL_DEPENDENCIES,
        OptionalMinimum,
        |_| ViolationDraft::new(ViolationCode::new("number.below_minimum")),
    ))
}

static TYPED_SIGNATURES: &[ValidatorSignature] = &[ValidatorSignature::new(
    InputType::of::<u32>(),
    OPTIONAL_DEPENDENCIES,
    prepare_optional_minimum,
)];
static TYPED_DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(TYPED_SIGNATURES);

#[test]
fn test_contextual_text_adapter_reads_dependency_slots_in_order() {
    let bound = TEXT_DESCRIPTOR
        .bind_for(ValidatorId::new("test.matches_pair"), InputType::Text, &[])
        .expect("declared dependency pair binds");
    let values = [ValidationValue::Text("alpha"), ValidationValue::Text("beta")];
    let context = BoundValidationContext::new(&values);

    assert_eq!(
        bound
            .validate(ValidationValue::Text("alpha:beta"), &context)
            .expect("matching dependencies pass"),
        ValidationOutcome::Valid,
    );
    let outcome = bound
        .validate(ValidationValue::Text("beta:alpha"), &context)
        .expect("a domain mismatch is a validation outcome");
    assert!(matches!(&outcome, ValidationOutcome::Invalid(violations)
        if violations.len() == 1
            && violations[0].code().as_str() == "text.dependency_mismatch"));
    assert!(!format!("{outcome:?}").contains("alpha"));
}

#[test]
fn test_contextual_typed_adapter_accepts_present_and_missing_optional_slot() {
    let bound = TYPED_DESCRIPTOR
        .bind_for(ValidatorId::new("test.optional_minimum"), InputType::of::<u32>(), &[])
        .expect("optional minimum dependency binds");
    let value = 5_u32;
    let minimum = 3_u32;
    let present = [ValidationValue::Typed(&minimum)];
    let missing = [ValidationValue::Missing];

    assert_eq!(
        bound
            .validate(ValidationValue::Typed(&value), &BoundValidationContext::new(&present),)
            .expect("value meets present minimum"),
        ValidationOutcome::Valid,
    );
    assert_eq!(
        bound
            .validate(ValidationValue::Typed(&value), &BoundValidationContext::new(&missing),)
            .expect("missing optional minimum is accepted"),
        ValidationOutcome::Valid,
    );
}

#[test]
fn test_contextual_typed_adapter_maps_domain_rejection_to_safe_violation() {
    let rule_id = ValidatorId::new("test.optional_minimum");
    let bound = TYPED_DESCRIPTOR
        .bind_for(rule_id, InputType::of::<u32>(), &[])
        .expect("optional minimum dependency binds");
    for (value, minimum) in [(2_u32, 3_u32), (1_234_567_890_u32, 1_234_567_891_u32)] {
        let dependencies = [ValidationValue::Typed(&minimum)];
        let outcome = bound
            .validate(
                ValidationValue::Typed(&value),
                &BoundValidationContext::new(&dependencies),
            )
            .expect("domain rejection must remain a successful execution outcome");
        let diagnostics = format!("{outcome:?}");
        let ValidationOutcome::Invalid(violations) = outcome else {
            panic!("value below the configured minimum must be invalid");
        };
        assert_eq!(violations.len(), 1, "one domain error maps to one violation");
        let violation = &violations[0];
        assert_eq!(violation.rule_id(), rule_id);
        assert_eq!(violation.code(), ViolationCode::new("number.below_minimum"));
        assert_eq!(violation.path(), &ValidationPath::root());
        assert!(violation.params().is_empty(), "the existing mapper emits no parameters");
        if value == 1_234_567_890 {
            assert!(
                !diagnostics.contains("1234567890"),
                "rejected value must stay out of Debug"
            );
            assert!(
                !diagnostics.contains("1234567891"),
                "dependency value must stay out of Debug"
            );
        }
    }
}

#[test]
fn test_contextual_adapters_reject_target_shape_before_invocation() {
    let text = prepare_contextual_text_validator(TEXT_DEPENDENCIES, MatchesDependencyPair, |_| {
        ViolationDraft::new(ViolationCode::new("text.dependency_mismatch"))
    });
    let error = text
        .validate(ValidationValue::Typed(&1_u32), &BoundValidationContext::new(&[]))
        .expect_err("text adapter must reject a typed target");
    assert_eq!(error.kind(), ExecutionErrorKind::InputTypeMismatch);

    let typed = prepare_contextual_typed_validator::<u32, _, _, _>(OPTIONAL_DEPENDENCIES, OptionalMinimum, |_| {
        ViolationDraft::new(ViolationCode::new("number.below_minimum"))
    });
    let error = typed
        .validate(ValidationValue::Text("5"), &BoundValidationContext::new(&[]))
        .expect_err("typed adapter must reject a text target");
    assert_eq!(error.kind(), ExecutionErrorKind::InputTypeMismatch);
}

#[test]
fn test_bound_validator_reports_dependency_slot_path_before_contextual_adapter() {
    let bound = TEXT_DESCRIPTOR
        .bind_for(ValidatorId::new("test.matches_pair"), InputType::Text, &[])
        .expect("declared dependency pair binds");
    let values = [ValidationValue::Missing, ValidationValue::Text("beta")];
    let first_path = ValidationPath::root().with_field("profile").with_field("first");
    let paths = [
        first_path.clone(),
        ValidationPath::root().with_field("profile").with_field("second"),
    ];
    let context =
        BoundValidationContext::new_with_paths(&values, &paths).expect("dependency paths correspond to the slots");

    let error = bound
        .validate(ValidationValue::Text("alpha:beta"), &context)
        .expect_err("missing required dependency must fail before the adapter");
    assert_eq!(error.kind(), ExecutionErrorKind::MissingRequiredDependencyValue);
    assert_eq!(error.dependency(), Some("first"));
    assert_eq!(error.path(), &first_path);
}

#[test]
fn test_text_context_closure_preserves_all_prepared_outcomes_and_execution_errors() {
    let text = prepare_text_with_context(&[], |value, _| {
        if value == "accept" {
            Ok(PreparedOutcome::Valid)
        } else if value == "reject twice" {
            PreparedOutcome::invalid(vec![
                ViolationDraft::new(ViolationCode::new("text.first")),
                ViolationDraft::new(ViolationCode::new("text.second")),
            ])
            .map_err(|_| ExecutionError::new(ExecutionErrorKind::AdapterContractViolation))
        } else {
            Err(ExecutionError::new(ExecutionErrorKind::ExternalFailure))
        }
    });

    assert_eq!(
        text.validate(ValidationValue::Text("accept"), &BoundValidationContext::new(&[]))
            .expect("closure can return a valid prepared outcome"),
        PreparedOutcome::Valid,
    );
    assert!(matches!(
        text.validate(ValidationValue::Text("reject twice"), &BoundValidationContext::new(&[]))
            .expect("closure can return multiple violation drafts"),
        PreparedOutcome::Invalid(drafts) if drafts.len() == 2
    ));
    let error = text
        .validate(
            ValidationValue::Text("external failure"),
            &BoundValidationContext::new(&[]),
        )
        .expect_err("execution errors must remain execution errors");
    assert_eq!(error.kind(), ExecutionErrorKind::ExternalFailure);
}

#[test]
fn test_typed_context_closure_rejects_wrong_target_type_before_invocation() {
    let calls = Arc::new(AtomicUsize::new(0));
    let prepared = prepare_typed_with_context::<u32, _>(&[], {
        let calls = Arc::clone(&calls);
        move |value, _| {
            calls.fetch_add(1, Ordering::SeqCst);
            if *value == 0 {
                Err(ExecutionError::new(ExecutionErrorKind::ExternalFailure))
            } else {
                Ok(PreparedOutcome::Valid)
            }
        }
    });

    let wrong_type = prepared
        .validate(ValidationValue::Text("secret"), &BoundValidationContext::new(&[]))
        .expect_err("typed closure must reject text before invocation");
    assert_eq!(wrong_type.kind(), ExecutionErrorKind::InputTypeMismatch);
    assert_eq!(calls.load(Ordering::SeqCst), 0);

    let zero = 0_u32;
    let error = prepared
        .validate(ValidationValue::Typed(&zero), &BoundValidationContext::new(&[]))
        .expect_err("closure execution failures must propagate");
    assert_eq!(error.kind(), ExecutionErrorKind::ExternalFailure);
    assert_eq!(calls.load(Ordering::SeqCst), 1);
}

static FALLIBLE_DEPENDENCIES: &[DependencySpec] = &[DependencySpec::new("expected", InputType::Text, false)];

fn prepare_fallible_dependency(_reader: &mut ArgumentReader<'_>) -> Result<Arc<dyn PreparedValidator>, BindError> {
    Ok(prepare_text_with_context(FALLIBLE_DEPENDENCIES, |_, context| {
        context.text(0)?;
        Err(ExecutionError::new(ExecutionErrorKind::ExternalFailure))
    }))
}

static FALLIBLE_SIGNATURES: &[ValidatorSignature] = &[ValidatorSignature::new(
    InputType::Text,
    FALLIBLE_DEPENDENCIES,
    prepare_fallible_dependency,
)];
static FALLIBLE_DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(FALLIBLE_SIGNATURES);

#[test]
fn test_bound_validator_attaches_rule_to_context_closure_execution_error() {
    let rule_id = ValidatorId::new("test.context_failure");
    let bound = FALLIBLE_DESCRIPTOR
        .bind_for(rule_id, InputType::Text, &[])
        .expect("fallible contextual validator binds");
    let values = [ValidationValue::Text("expected")];
    let paths = [ValidationPath::root().with_field("profile").with_field("expected")];
    let context = BoundValidationContext::new_with_paths(&values, &paths).expect("one path per dependency");

    let error = bound
        .validate(ValidationValue::Text("candidate"), &context)
        .expect_err("closure execution failure must not become a validation violation");
    assert_eq!(error.kind(), ExecutionErrorKind::ExternalFailure);
    assert_eq!(error.rule_id(), Some(rule_id));
}