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::convert::Infallible;

use qubit_validator::BindError;
use qubit_validator::BindErrorKind;
use qubit_validator::BoundValidationContext;
use qubit_validator::ExecutionError;
use qubit_validator::ExecutionErrorKind;
use qubit_validator::InputType;
use qubit_validator::PathSegment;
use qubit_validator::PreparedOutcome;
use qubit_validator::SkipReason;
use qubit_validator::ValidationOutcome;
use qubit_validator::ValidationPath;
use qubit_validator::ValidationReport;
use qubit_validator::ValidationValue;
use qubit_validator::Validator;
use qubit_validator::ValidatorId;
use qubit_validator::Violation;
use qubit_validator::ViolationCode;
use qubit_validator::ViolationDraft;
use qubit_validator::ViolationParam;

struct NonEmpty;

impl Validator<str> for NonEmpty {
    type Error = Infallible;

    fn validate(&self, value: &str, _: &()) -> Result<(), Self::Error> {
        assert!(!value.is_empty());
        Ok(())
    }
}

#[test]
fn test_validator_uses_shared_immutable_context() {
    NonEmpty.validate("value", &()).expect("value is valid");
}

#[test]
fn test_debug_surfaces_redact_values_and_show_safe_shapes() {
    let secret = "sensitive-input";
    let value = ValidationValue::Text(secret);
    let values = [value];
    let context = BoundValidationContext::new(&values);

    for debug in [format!("{value:?}"), format!("{context:?}")] {
        assert!(!debug.contains(secret));
    }
    assert_eq!(InputType::Text, value.input_type().expect("text has an input type"));

    let dynamic_entry = ValidationPath::root().with_map_entry(0).with_map_value();
    assert_eq!(dynamic_entry.render(), ".<map-entry:0>.<map-value>");
    assert!(!dynamic_entry.render().contains(secret));
}

#[test]
fn test_violation_draft_and_prepared_outcome_debug_redact_static_content() {
    let field = "field-redaction-sentinel-unique";
    let parameter_name = "parameter-redaction-sentinel-unique";
    let token = "token-redaction-sentinel-unique";
    let draft = ViolationDraft::new(ViolationCode::new("text.redaction_check"))
        .with_path(ValidationPath::root().with_field(field))
        .with_param(parameter_name, ViolationParam::Token(token));

    let draft_debug = format!("{draft:?}");
    assert!(draft_debug.contains("text.redaction_check"));
    assert!(draft_debug.contains("parameter_count: 1"));
    for sentinel in [field, parameter_name, token] {
        assert!(!draft_debug.contains(sentinel), "draft Debug must redact {sentinel}");
    }

    let outcome = PreparedOutcome::Invalid(vec![draft]);
    let outcome_debug = format!("{outcome:?}");
    assert!(outcome_debug.contains("text.redaction_check"));
    for sentinel in [field, parameter_name, token] {
        assert!(
            !outcome_debug.contains(sentinel),
            "prepared outcome Debug must redact {sentinel}"
        );
    }
}

#[test]
fn test_violation_code_accepts_stable_dot_separated_names() {
    let code = ViolationCode::try_new("text.too_short").expect("valid code");

    assert_eq!(code.as_str(), "text.too_short");
    assert!(ViolationCode::try_new("text..too_short").is_err());
}

#[test]
fn test_path_builders_preserve_segments_without_map_keys() {
    let path = ValidationPath::root()
        .with_field("people")
        .with_index(2)
        .with_map_entry(4)
        .with_map_key();

    assert_eq!(
        path.as_segments(),
        &[
            PathSegment::Field("people"),
            PathSegment::Index(2),
            PathSegment::MapEntry(4),
            PathSegment::MapKey,
        ]
    );
    assert!(!format!("{path:?}").contains("people"));
    assert_eq!(path.render(), "people[2].<map-entry:4>.<map-key>");
}

#[test]
fn test_violation_and_report_keep_structured_safe_data() {
    let violation = Violation::new(
        ValidatorId::new("qubit.rules.text"),
        ViolationCode::new("text.too_short"),
    )
    .with_path(ValidationPath::root().with_field("name"))
    .with_param("min", ViolationParam::Unsigned(2));

    let mut report = ValidationReport::new();
    assert!(
        report
            .record_outcome(
                0,
                ValidationPath::root(),
                ValidationOutcome::invalid(vec![violation]).expect("a violation is present")
            )
            .expect("violation is accepted")
            .complete()
    );
    assert!(
        report
            .record_outcome(
                1,
                ValidationPath::root().with_field("optional"),
                ValidationOutcome::missing_optional()
            )
            .expect("missing optional is a valid outcome")
            .complete()
    );

    assert!(!report.is_valid());
    assert_eq!(report.violations().len(), 1);
    assert_eq!(report.skipped().len(), 1);
    assert!(!format!("{:?}", report).contains("name"));
    assert!(!format!("{:?}", report).contains("min"));
}

#[test]
fn test_failed_prerequisite_skip_is_invalid_and_outcome_is_explicit() {
    let mut report = ValidationReport::new();
    let source = report
        .record_outcome(
            1,
            ValidationPath::root().with_field("credential"),
            ValidationOutcome::invalid(vec![Violation::new(
                ValidatorId::new("qubit.rules.credential"),
                ViolationCode::new("credential.invalid"),
            )])
            .expect("a source violation is present"),
        )
        .expect("source failure is retained");
    let id = source.failure_ids()[0];
    report
        .record_outcome(
            2,
            ValidationPath::root(),
            ValidationOutcome::failed_prerequisite(vec![id]).expect("a failed prerequisite is present"),
        )
        .expect("failed prerequisite is stored");

    assert!(!report.is_valid());
    assert_eq!(report.skipped()[0].reason(), SkipReason::FailedPrerequisite);
    assert_eq!(report.skipped()[0].prerequisites(), &[id]);
}

#[test]
fn test_execution_and_bind_errors_expose_kind_without_source_or_values() {
    let execution =
        ExecutionError::new(ExecutionErrorKind::ExternalFailure).with_rule(ValidatorId::new("qubit.rules.remote"));
    assert_eq!(execution.kind(), ExecutionErrorKind::ExternalFailure);
    assert!(std::error::Error::source(&execution).is_none());
    assert!(execution.to_string().contains(&execution.kind().to_string()));
    assert!(format!("{execution:?}").contains("ExternalFailure"));

    let parameter = "parameter-redaction-sentinel-unique";
    let dependency = "dependency-redaction-sentinel-unique";
    let bind = BindError::new(BindErrorKind::ParameterTypeMismatch)
        .with_parameter(parameter)
        .with_dependency(dependency);
    assert_eq!(bind.kind(), BindErrorKind::ParameterTypeMismatch);
    assert_eq!(bind.parameter(), Some(parameter));
    assert_eq!(bind.dependency(), Some(dependency));
    let display = bind.to_string();
    let debug = format!("{bind:?}");
    assert!(display.contains(&bind.kind().to_string()));
    assert!(debug.contains("has_parameter: true"));
    assert!(debug.contains("has_dependency: true"));
    for rendered in [&display, &debug] {
        assert!(!rendered.contains(parameter), "parameter name must be redacted");
        assert!(!rendered.contains(dependency), "dependency name must be redacted");
    }
}

#[derive(Debug)]
struct SensitiveCause {
    message: &'static str,
}

impl std::fmt::Display for SensitiveCause {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(self.message)
    }
}

impl std::error::Error for SensitiveCause {}

#[test]
fn test_trusted_execution_source_is_explicit_and_ordinary_formats_redact_it() {
    let error = ExecutionError::new(ExecutionErrorKind::ExternalFailure).with_trusted_source(SensitiveCause {
        message: "source-secret",
    });
    let source = error.trusted_source().expect("trusted cause is retained");

    assert_eq!(source.to_string(), "source-secret");
    assert!(source.downcast_ref::<SensitiveCause>().is_some());
    assert!(!format!("{error:?}").contains("source-secret"));
    assert!(!error.to_string().contains("source-secret"));
    assert!(std::error::Error::source(&error).is_none());

    let without_source = ExecutionError::new(ExecutionErrorKind::ExternalFailure);
    assert!(without_source.trusted_source().is_none());
}