//! Sealed playbook validator. //! //! PRD checks: //! - Operation format (`fill: Role xN in City, ST`) //! - endorsed_names non-empty, ≤ target_count × 2 //! - fingerprint populated (Phase 25 validity window requirement) use crate::{Artifact, Report, Validator, ValidationError}; use std::time::Instant; pub struct PlaybookValidator; impl Validator for PlaybookValidator { fn name(&self) -> &'static str { "staffing.playbook" } fn validate(&self, artifact: &Artifact) -> Result { let started = Instant::now(); let value = match artifact { Artifact::Playbook(v) => v, other => return Err(ValidationError::Schema { field: "artifact".into(), reason: format!("PlaybookValidator expects Playbook, got {other:?}"), }), }; // Operation format: "fill: Role xN in City, ST" — at minimum // we check the string-shape. Fuller grammar parse lives in // phase 25 code where operations are structured beyond strings. let op = value.get("operation").and_then(|v| v.as_str()).ok_or( ValidationError::Schema { field: "operation".into(), reason: "missing or not a string".into(), }, )?; if !op.starts_with("fill:") { return Err(ValidationError::Schema { field: "operation".into(), reason: format!("expected `fill: ...` prefix, got {op:?}"), }); } let endorsed = value.get("endorsed_names").and_then(|v| v.as_array()).ok_or( ValidationError::Schema { field: "endorsed_names".into(), reason: "missing or not an array".into(), }, )?; if endorsed.is_empty() { return Err(ValidationError::Completeness { reason: "endorsed_names must be non-empty".into(), }); } if let Some(target) = value.get("target_count").and_then(|v| v.as_u64()) { let max = (target * 2) as usize; if endorsed.len() > max { return Err(ValidationError::Completeness { reason: format!( "endorsed_names ({}) exceeds target_count × 2 ({max})", endorsed.len() ), }); } } if value.get("fingerprint").and_then(|v| v.as_str()).map_or(true, |s| s.is_empty()) { return Err(ValidationError::Schema { field: "fingerprint".into(), reason: "missing — required for Phase 25 validity window".into(), }); } Ok(Report { findings: vec![], elapsed_ms: started.elapsed().as_millis() as u64, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn well_formed_playbook_passes() { let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({ "operation": "fill: Welder x2 in Toledo, OH", "endorsed_names": ["W-123", "W-456"], "target_count": 2, "fingerprint": "abc123" }))); assert!(r.is_ok(), "got {:?}", r); } #[test] fn empty_endorsed_names_fails_completeness() { let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({ "operation": "fill: Welder x2 in Toledo, OH", "endorsed_names": [], "fingerprint": "abc" }))); assert!(matches!(r, Err(ValidationError::Completeness { .. }))); } #[test] fn overfull_endorsed_names_fails_completeness() { let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({ "operation": "fill: Welder x1 in Toledo, OH", "endorsed_names": ["a", "b", "c"], "target_count": 1, "fingerprint": "abc" }))); assert!(matches!(r, Err(ValidationError::Completeness { .. }))); } #[test] fn missing_fingerprint_fails_schema() { let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({ "operation": "fill: X x1 in A, B", "endorsed_names": ["a"] }))); assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "fingerprint")); } #[test] fn wrong_operation_prefix_fails_schema() { let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({ "operation": "sms_draft: hello", "endorsed_names": ["a"], "fingerprint": "x" }))); assert!(matches!(r, Err(ValidationError::Schema { .. }))); } }