use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
use crate::types::{ContentHash, ProposalId, Timestamp};
#[derive(Clone)]
pub(crate) struct ValidationToken(());
impl ValidationToken {
pub(crate) fn new() -> Self {
Self(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub name: String,
pub passed: bool,
pub message: Option<String>,
}
impl CheckResult {
pub fn passed(name: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: true,
message: None,
}
}
pub fn failed(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: false,
message: Some(message.into()),
}
}
pub fn passed_with_message(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: true,
message: Some(message.into()),
}
}
}
#[derive(Clone)]
pub struct ValidationReport {
proposal_id: ProposalId,
checks: Vec<CheckResult>,
policy_version: ContentHash,
validated_at: Timestamp,
_token: ValidationToken,
}
impl ValidationReport {
pub(crate) fn new(
proposal_id: ProposalId,
checks: Vec<CheckResult>,
policy_version: ContentHash,
) -> Self {
Self {
proposal_id,
checks,
policy_version,
validated_at: Timestamp::now(),
_token: ValidationToken::new(),
}
}
pub fn proposal_id(&self) -> &ProposalId {
&self.proposal_id
}
pub fn checks(&self) -> &[CheckResult] {
&self.checks
}
pub fn policy_version(&self) -> &ContentHash {
&self.policy_version
}
pub fn validated_at(&self) -> &Timestamp {
&self.validated_at
}
pub fn all_passed(&self) -> bool {
self.checks.iter().all(|c| c.passed)
}
pub fn failed_checks(&self) -> Vec<&str> {
self.checks
.iter()
.filter(|c| !c.passed)
.map(|c| c.name.as_str())
.collect()
}
}
impl std::fmt::Debug for ValidationReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidationReport")
.field("proposal_id", &self.proposal_id)
.field("checks", &self.checks)
.field("policy_version", &self.policy_version)
.field("validated_at", &self.validated_at)
.finish()
}
}
#[derive(Debug, Clone, Default)]
pub struct ValidationPolicy {
pub required_checks: Vec<String>,
pub allow_warnings: bool,
version_hash: ContentHash,
}
impl ValidationPolicy {
pub fn new() -> Self {
Self {
required_checks: Vec::new(),
allow_warnings: true,
version_hash: ContentHash::zero(),
}
}
pub fn with_required_check(mut self, check: impl Into<String>) -> Self {
self.required_checks.push(check.into());
self.update_version_hash();
self
}
pub fn with_allow_warnings(mut self, allow: bool) -> Self {
self.allow_warnings = allow;
self.update_version_hash();
self
}
pub fn version_hash(&self) -> &ContentHash {
&self.version_hash
}
fn update_version_hash(&mut self) {
let mut hash = [0u8; 32];
let mut fnv: u64 = 0xcbf29ce484222325;
for check in &self.required_checks {
for byte in check.bytes() {
fnv ^= byte as u64;
fnv = fnv.wrapping_mul(0x100000001b3);
}
}
fnv ^= self.allow_warnings as u64;
fnv = fnv.wrapping_mul(0x100000001b3);
hash[..8].copy_from_slice(&fnv.to_le_bytes());
self.version_hash = ContentHash::new(hash);
}
}
#[derive(Debug, Clone, Default)]
pub struct ValidationContext {
pub tenant_id: Option<String>,
pub session_id: Option<String>,
pub metadata: HashMap<String, serde_json::Value>,
}
impl ValidationContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
self.tenant_id = Some(tenant.into());
self
}
pub fn with_session(mut self, session: impl Into<String>) -> Self {
self.session_id = Some(session.into());
self
}
pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
}
#[derive(Debug, Clone, Error)]
pub enum ValidationError {
#[error("check '{name}' failed: {reason}")]
CheckFailed {
name: String,
reason: String,
},
#[error("policy violation: {0}")]
PolicyViolation(String),
#[error("missing required check: {0}")]
MissingCheck(String),
#[error("invalid input: {0}")]
InvalidInput(String),
}
impl ValidationError {
pub fn check_failed(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self::CheckFailed {
name: name.into(),
reason: reason.into(),
}
}
pub fn policy_violation(message: impl Into<String>) -> Self {
Self::PolicyViolation(message.into())
}
pub fn missing_check(check: impl Into<String>) -> Self {
Self::MissingCheck(check.into())
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::InvalidInput(message.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_result_passed() {
let check = CheckResult::passed("schema_valid");
assert!(check.passed);
assert_eq!(check.name, "schema_valid");
assert!(check.message.is_none());
}
#[test]
fn check_result_failed() {
let check =
CheckResult::failed("confidence_threshold", "confidence 0.3 below threshold 0.5");
assert!(!check.passed);
assert_eq!(check.name, "confidence_threshold");
assert_eq!(
check.message,
Some("confidence 0.3 below threshold 0.5".to_string())
);
}
#[test]
fn validation_report_creation() {
let report = ValidationReport::new(
ProposalId::new("prop-001"),
vec![
CheckResult::passed("check_1"),
CheckResult::passed("check_2"),
],
ContentHash::zero(),
);
assert_eq!(report.proposal_id().as_str(), "prop-001");
assert_eq!(report.checks().len(), 2);
assert!(report.all_passed());
assert!(report.failed_checks().is_empty());
}
#[test]
fn validation_report_with_failures() {
let report = ValidationReport::new(
ProposalId::new("prop-002"),
vec![
CheckResult::passed("check_1"),
CheckResult::failed("check_2", "too low"),
],
ContentHash::zero(),
);
assert!(!report.all_passed());
assert_eq!(report.failed_checks(), vec!["check_2"]);
}
#[test]
fn validation_policy_builder() {
let policy = ValidationPolicy::new()
.with_required_check("schema_valid")
.with_required_check("confidence_threshold")
.with_allow_warnings(false);
assert_eq!(policy.required_checks.len(), 2);
assert!(!policy.allow_warnings);
assert_ne!(policy.version_hash(), &ContentHash::zero());
}
#[test]
fn validation_context_builder() {
let ctx = ValidationContext::new()
.with_tenant("tenant-123")
.with_session("session-456")
.with_metadata("custom_key", serde_json::json!({"value": 42}));
assert_eq!(ctx.tenant_id, Some("tenant-123".to_string()));
assert_eq!(ctx.session_id, Some("session-456".to_string()));
assert!(ctx.metadata.contains_key("custom_key"));
}
#[test]
fn validation_error_display() {
let err = ValidationError::check_failed("schema_valid", "missing required field");
assert_eq!(
err.to_string(),
"check 'schema_valid' failed: missing required field"
);
let err = ValidationError::policy_violation("too many warnings");
assert_eq!(err.to_string(), "policy violation: too many warnings");
let err = ValidationError::missing_check("human_review");
assert_eq!(err.to_string(), "missing required check: human_review");
}
#[test]
fn validation_report_debug() {
let report = ValidationReport::new(
ProposalId::new("prop-003"),
vec![CheckResult::passed("test")],
ContentHash::zero(),
);
let debug = format!("{:?}", report);
assert!(debug.contains("ValidationReport"));
assert!(debug.contains("prop-003"));
assert!(!debug.contains("_token"));
}
}