1use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use thiserror::Error;
25
26use crate::types::{ContentHash, ProposalId, Timestamp};
27
28#[derive(Clone)]
37pub(crate) struct ValidationToken(());
38
39impl ValidationToken {
40 pub(crate) fn new() -> Self {
44 Self(())
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CheckResult {
57 pub name: String,
59 pub passed: bool,
61 pub message: Option<String>,
63}
64
65impl CheckResult {
66 pub fn passed(name: impl Into<String>) -> Self {
68 Self {
69 name: name.into(),
70 passed: true,
71 message: None,
72 }
73 }
74
75 pub fn failed(name: impl Into<String>, message: impl Into<String>) -> Self {
77 Self {
78 name: name.into(),
79 passed: false,
80 message: Some(message.into()),
81 }
82 }
83
84 pub fn passed_with_message(name: impl Into<String>, message: impl Into<String>) -> Self {
86 Self {
87 name: name.into(),
88 passed: true,
89 message: Some(message.into()),
90 }
91 }
92}
93
94#[derive(Clone)]
109pub struct ValidationReport {
110 proposal_id: ProposalId,
112 checks: Vec<CheckResult>,
114 policy_version: ContentHash,
116 validated_at: Timestamp,
118 _token: ValidationToken,
120}
121
122impl ValidationReport {
123 pub(crate) fn new(
127 proposal_id: ProposalId,
128 checks: Vec<CheckResult>,
129 policy_version: ContentHash,
130 ) -> Self {
131 Self {
132 proposal_id,
133 checks,
134 policy_version,
135 validated_at: Timestamp::now(),
136 _token: ValidationToken::new(),
137 }
138 }
139
140 pub fn proposal_id(&self) -> &ProposalId {
142 &self.proposal_id
143 }
144
145 pub fn checks(&self) -> &[CheckResult] {
147 &self.checks
148 }
149
150 pub fn policy_version(&self) -> &ContentHash {
152 &self.policy_version
153 }
154
155 pub fn validated_at(&self) -> &Timestamp {
157 &self.validated_at
158 }
159
160 pub fn all_passed(&self) -> bool {
162 self.checks.iter().all(|c| c.passed)
163 }
164
165 pub fn failed_checks(&self) -> Vec<&str> {
167 self.checks
168 .iter()
169 .filter(|c| !c.passed)
170 .map(|c| c.name.as_str())
171 .collect()
172 }
173}
174
175impl std::fmt::Debug for ValidationReport {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.debug_struct("ValidationReport")
179 .field("proposal_id", &self.proposal_id)
180 .field("checks", &self.checks)
181 .field("policy_version", &self.policy_version)
182 .field("validated_at", &self.validated_at)
183 .finish()
184 }
185}
186
187#[derive(Debug, Clone, Default)]
195pub struct ValidationPolicy {
196 pub required_checks: Vec<String>,
198 pub allow_warnings: bool,
200 version_hash: ContentHash,
202}
203
204impl ValidationPolicy {
205 pub fn new() -> Self {
207 Self {
208 required_checks: Vec::new(),
209 allow_warnings: true,
210 version_hash: ContentHash::zero(),
211 }
212 }
213
214 pub fn with_required_check(mut self, check: impl Into<String>) -> Self {
216 self.required_checks.push(check.into());
217 self.update_version_hash();
218 self
219 }
220
221 pub fn with_allow_warnings(mut self, allow: bool) -> Self {
223 self.allow_warnings = allow;
224 self.update_version_hash();
225 self
226 }
227
228 pub fn version_hash(&self) -> &ContentHash {
230 &self.version_hash
231 }
232
233 fn update_version_hash(&mut self) {
235 let mut hash = [0u8; 32];
237 let mut fnv: u64 = 0xcbf29ce484222325;
238
239 for check in &self.required_checks {
240 for byte in check.bytes() {
241 fnv ^= byte as u64;
242 fnv = fnv.wrapping_mul(0x100000001b3);
243 }
244 }
245
246 fnv ^= self.allow_warnings as u64;
247 fnv = fnv.wrapping_mul(0x100000001b3);
248
249 hash[..8].copy_from_slice(&fnv.to_le_bytes());
251 self.version_hash = ContentHash::new(hash);
252 }
253}
254
255#[derive(Debug, Clone, Default)]
263pub struct ValidationContext {
264 pub tenant_id: Option<String>,
266 pub session_id: Option<String>,
268 pub metadata: HashMap<String, serde_json::Value>,
270}
271
272impl ValidationContext {
273 pub fn new() -> Self {
275 Self::default()
276 }
277
278 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
280 self.tenant_id = Some(tenant.into());
281 self
282 }
283
284 pub fn with_session(mut self, session: impl Into<String>) -> Self {
286 self.session_id = Some(session.into());
287 self
288 }
289
290 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
292 self.metadata.insert(key.into(), value);
293 self
294 }
295}
296
297#[derive(Debug, Clone, Error)]
303pub enum ValidationError {
304 #[error("check '{name}' failed: {reason}")]
306 CheckFailed {
307 name: String,
309 reason: String,
311 },
312
313 #[error("policy violation: {0}")]
315 PolicyViolation(String),
316
317 #[error("missing required check: {0}")]
319 MissingCheck(String),
320
321 #[error("invalid input: {0}")]
323 InvalidInput(String),
324}
325
326impl ValidationError {
327 pub fn check_failed(name: impl Into<String>, reason: impl Into<String>) -> Self {
329 Self::CheckFailed {
330 name: name.into(),
331 reason: reason.into(),
332 }
333 }
334
335 pub fn policy_violation(message: impl Into<String>) -> Self {
337 Self::PolicyViolation(message.into())
338 }
339
340 pub fn missing_check(check: impl Into<String>) -> Self {
342 Self::MissingCheck(check.into())
343 }
344
345 pub fn invalid_input(message: impl Into<String>) -> Self {
347 Self::InvalidInput(message.into())
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn check_result_passed() {
357 let check = CheckResult::passed("schema_valid");
358 assert!(check.passed);
359 assert_eq!(check.name, "schema_valid");
360 assert!(check.message.is_none());
361 }
362
363 #[test]
364 fn check_result_failed() {
365 let check = CheckResult::failed("confidence_threshold", "confidence 0.3 below threshold 0.5");
366 assert!(!check.passed);
367 assert_eq!(check.name, "confidence_threshold");
368 assert_eq!(check.message, Some("confidence 0.3 below threshold 0.5".to_string()));
369 }
370
371 #[test]
372 fn validation_report_creation() {
373 let report = ValidationReport::new(
374 ProposalId::new("prop-001"),
375 vec![
376 CheckResult::passed("check_1"),
377 CheckResult::passed("check_2"),
378 ],
379 ContentHash::zero(),
380 );
381
382 assert_eq!(report.proposal_id().as_str(), "prop-001");
383 assert_eq!(report.checks().len(), 2);
384 assert!(report.all_passed());
385 assert!(report.failed_checks().is_empty());
386 }
387
388 #[test]
389 fn validation_report_with_failures() {
390 let report = ValidationReport::new(
391 ProposalId::new("prop-002"),
392 vec![
393 CheckResult::passed("check_1"),
394 CheckResult::failed("check_2", "too low"),
395 ],
396 ContentHash::zero(),
397 );
398
399 assert!(!report.all_passed());
400 assert_eq!(report.failed_checks(), vec!["check_2"]);
401 }
402
403 #[test]
404 fn validation_policy_builder() {
405 let policy = ValidationPolicy::new()
406 .with_required_check("schema_valid")
407 .with_required_check("confidence_threshold")
408 .with_allow_warnings(false);
409
410 assert_eq!(policy.required_checks.len(), 2);
411 assert!(!policy.allow_warnings);
412 assert_ne!(policy.version_hash(), &ContentHash::zero());
414 }
415
416 #[test]
417 fn validation_context_builder() {
418 let ctx = ValidationContext::new()
419 .with_tenant("tenant-123")
420 .with_session("session-456")
421 .with_metadata("custom_key", serde_json::json!({"value": 42}));
422
423 assert_eq!(ctx.tenant_id, Some("tenant-123".to_string()));
424 assert_eq!(ctx.session_id, Some("session-456".to_string()));
425 assert!(ctx.metadata.contains_key("custom_key"));
426 }
427
428 #[test]
429 fn validation_error_display() {
430 let err = ValidationError::check_failed("schema_valid", "missing required field");
431 assert_eq!(
432 err.to_string(),
433 "check 'schema_valid' failed: missing required field"
434 );
435
436 let err = ValidationError::policy_violation("too many warnings");
437 assert_eq!(err.to_string(), "policy violation: too many warnings");
438
439 let err = ValidationError::missing_check("human_review");
440 assert_eq!(err.to_string(), "missing required check: human_review");
441 }
442
443 #[test]
444 fn validation_report_debug() {
445 let report = ValidationReport::new(
446 ProposalId::new("prop-003"),
447 vec![CheckResult::passed("test")],
448 ContentHash::zero(),
449 );
450
451 let debug = format!("{:?}", report);
453 assert!(debug.contains("ValidationReport"));
454 assert!(debug.contains("prop-003"));
455 assert!(!debug.contains("_token"));
456 }
457
458 }