//! Semantic validation for Storybook entities //! //! This module validates semantic constraints that can't be checked during //! parsing: //! - Reserved keyword conflicts in field names //! - Trait value ranges //! - Schedule time overlaps //! - Life arc transition validity //! - Behavior tree action registry checks //! - Relationship bond values (0.0 .. 1.0) use std::collections::HashSet; use crate::{ resolve::{ ErrorCollector, ResolveError, Result, }, syntax::ast::*, }; /// List of reserved keywords that cannot be used as field names const RESERVED_KEYWORDS: &[&str] = &[ // Top-level declaration keywords "character", "template", "life_arc", "schedule", "behavior", "institution", "relationship", "location", "species", "enum", // Statement keywords "use", "state", "on", "as", "remove", "append", "strict", "include", "from", // Expression keywords "self", "other", "forall", "exists", "in", "where", "and", "or", "not", "is", "true", "false", ]; /// Validate that field names don't conflict with reserved keywords pub fn validate_no_reserved_keywords(fields: &[Field], collector: &mut ErrorCollector) { for field in fields { if RESERVED_KEYWORDS.contains(&field.name.as_str()) { collector.add(ResolveError::ValidationError { message: format!( "Field name '{}' is a reserved keyword and cannot be used", field.name ), help: Some(format!( "The name '{}' is reserved by the Storybook language. Try using a different name like '{}Type', '{}Value', or 'my{}'.", field.name, field.name, field.name, field.name.chars().next().unwrap_or('x').to_uppercase().collect::() + &field.name[1..] )), }); } } } /// Validate trait values are within valid ranges pub fn validate_trait_ranges(fields: &[Field], collector: &mut ErrorCollector) { for field in fields { match &field.value { | Value::Float(f) => { // Normalized trait values should be 0.0 .. 1.0 if (field.name.ends_with("_normalized") || field.name == "bond" || field.name == "trust" || field.name == "love") && !(0.0..=1.0).contains(f) { collector.add(ResolveError::TraitOutOfRange { field: field.name.clone(), value: f.to_string(), min: 0.0, max: 1.0, }); } }, | Value::Int(i) => { // Age should be reasonable if field.name == "age" && (*i < 0 || *i > 150) { collector.add(ResolveError::TraitOutOfRange { field: "age".to_string(), value: i.to_string(), min: 0.0, max: 150.0, }); } }, | _ => {}, } } } /// Validate relationship bond values are in [0.0, 1.0] pub fn validate_relationship_bonds(relationships: &[Relationship], collector: &mut ErrorCollector) { for rel in relationships { for field in &rel.fields { if field.name == "bond" { if let Value::Float(f) = field.value { if !(0.0..=1.0).contains(&f) { collector.add(ResolveError::TraitOutOfRange { field: "bond".to_string(), value: f.to_string(), min: 0.0, max: 1.0, }); } } } } // Validate self/other blocks if present for participant in &rel.participants { if let Some(ref self_fields) = participant.self_block { validate_trait_ranges(self_fields, collector); } if let Some(ref other_fields) = participant.other_block { validate_trait_ranges(other_fields, collector); } } } } /// Validate schedule blocks don't overlap in time pub fn validate_schedule_overlaps(schedule: &Schedule, collector: &mut ErrorCollector) { // Sort blocks by start time let mut sorted_blocks: Vec<_> = schedule.blocks.iter().collect(); sorted_blocks.sort_by_key(|b| (b.start.hour as u32) * 60 + (b.start.minute as u32)); // Check for overlaps for i in 0..sorted_blocks.len() { for j in (i + 1)..sorted_blocks.len() { let block1 = sorted_blocks[i]; let block2 = sorted_blocks[j]; let end1 = (block1.end.hour as u32) * 60 + (block1.end.minute as u32); let start2 = (block2.start.hour as u32) * 60 + (block2.start.minute as u32); // Check if blocks overlap if end1 > start2 { collector.add(ResolveError::ScheduleOverlap { block1: format!( "{} ({}:{:02}-{}:{:02})", block1.activity, block1.start.hour, block1.start.minute, block1.end.hour, block1.end.minute ), block2: format!( "{} ({}:{:02}-{}:{:02})", block2.activity, block2.start.hour, block2.start.minute, block2.end.hour, block2.end.minute ), end1: format!("{}:{:02}", block1.end.hour, block1.end.minute), start2: format!("{}:{:02}", block2.start.hour, block2.start.minute), }); } } } } /// Validate life arc state machine has valid transitions pub fn validate_life_arc_transitions(life_arc: &LifeArc, collector: &mut ErrorCollector) { // Collect all state names let mut state_names = HashSet::new(); for state in &life_arc.states { state_names.insert(state.name.clone()); } // Validate all transitions point to valid states for state in &life_arc.states { for transition in &state.transitions { if !state_names.contains(&transition.to) { let available_states = state_names .iter() .map(|s| format!("'{}'", s)) .collect::>() .join(", "); collector.add(ResolveError::UnknownLifeArcState { life_arc: life_arc.name.clone(), state: state.name.clone(), target: transition.to.clone(), available_states, }); } } } // Warn if states have no outgoing transitions (terminal states) // This is not an error, just informational } /// Validate behavior tree actions are known /// /// If action_registry is empty, skips validation (no schema provided). pub fn validate_behavior_tree_actions( tree: &Behavior, action_registry: &HashSet, collector: &mut ErrorCollector, ) { // Skip validation if no action schema was provided if action_registry.is_empty() { return; } validate_tree_node_actions(&tree.root, action_registry, &tree.name, collector) } fn validate_tree_node_actions( node: &BehaviorNode, action_registry: &HashSet, tree_name: &str, collector: &mut ErrorCollector, ) { match node { | BehaviorNode::Sequence(children) | BehaviorNode::Selector(children) => { for child in children { validate_tree_node_actions(child, action_registry, tree_name, collector); } }, | BehaviorNode::Action(name, _params) => { if !action_registry.contains(name) { collector.add(ResolveError::UnknownBehaviorAction { tree: tree_name.to_string(), action: name.clone(), }); } }, | BehaviorNode::Condition(_) => { // Conditions are validated separately via expression validation }, | BehaviorNode::Decorator(_name, child) => { validate_tree_node_actions(child, action_registry, tree_name, collector); }, | BehaviorNode::SubTree(_path) => { // SubTree references validated separately }, } } /// Validate an entire file /// /// Collects all validation errors and returns them together instead of failing /// fast. pub fn validate_file(file: &File, action_registry: &HashSet) -> Result<()> { let mut collector = ErrorCollector::new(); for decl in &file.declarations { match decl { | Declaration::Character(c) => { validate_trait_ranges(&c.fields, &mut collector); }, | Declaration::Relationship(r) => { validate_relationship_bonds(std::slice::from_ref(r), &mut collector); }, | Declaration::Schedule(s) => { validate_schedule_overlaps(s, &mut collector); }, | Declaration::LifeArc(la) => { validate_life_arc_transitions(la, &mut collector); }, | Declaration::Behavior(bt) => { validate_behavior_tree_actions(bt, action_registry, &mut collector); }, | _ => { // Other declarations don't need validation yet }, } } collector.into_result(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_trait_ranges() { let fields = vec![ Field { name: "bond".to_string(), value: Value::Float(0.8), span: Span::new(0, 10), }, Field { name: "age".to_string(), value: Value::Int(30), span: Span::new(0, 10), }, ]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_invalid_bond_value_too_high() { let fields = vec![Field { name: "bond".to_string(), value: Value::Float(1.5), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_invalid_bond_value_negative() { let fields = vec![Field { name: "bond".to_string(), value: Value::Float(-0.1), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_invalid_age_negative() { let fields = vec![Field { name: "age".to_string(), value: Value::Int(-5), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_invalid_age_too_high() { let fields = vec![Field { name: "age".to_string(), value: Value::Int(200), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_valid_relationship_bond() { let relationship = Relationship { name: "Test".to_string(), participants: vec![], fields: vec![Field { name: "bond".to_string(), value: Value::Float(0.9), span: Span::new(0, 10), }], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_relationship_bonds(&[relationship], &mut collector); assert!(!collector.has_errors()); } #[test] fn test_invalid_relationship_bond() { let relationship = Relationship { name: "Test".to_string(), participants: vec![], fields: vec![Field { name: "bond".to_string(), value: Value::Float(1.2), span: Span::new(0, 10), }], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_relationship_bonds(&[relationship], &mut collector); assert!(collector.has_errors()); } #[test] fn test_life_arc_valid_transitions() { let life_arc = LifeArc { name: "Test".to_string(), states: vec![ ArcState { name: "start".to_string(), on_enter: None, transitions: vec![Transition { to: "end".to_string(), condition: Expr::Identifier(vec!["ready".to_string()]), span: Span::new(0, 10), }], span: Span::new(0, 50), }, ArcState { name: "end".to_string(), on_enter: None, transitions: vec![], span: Span::new(50, 100), }, ], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_life_arc_transitions(&life_arc, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_life_arc_invalid_transition() { let life_arc = LifeArc { name: "Test".to_string(), states: vec![ArcState { name: "start".to_string(), on_enter: None, transitions: vec![Transition { to: "nonexistent".to_string(), condition: Expr::Identifier(vec!["ready".to_string()]), span: Span::new(0, 10), }], span: Span::new(0, 50), }], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_life_arc_transitions(&life_arc, &mut collector); assert!(collector.has_errors()); } #[test] fn test_behavior_tree_valid_actions() { let mut registry = HashSet::new(); registry.insert("walk".to_string()); registry.insert("eat".to_string()); let tree = Behavior { name: "Test".to_string(), root: BehaviorNode::Sequence(vec![ BehaviorNode::Action("walk".to_string(), vec![]), BehaviorNode::Action("eat".to_string(), vec![]), ]), span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_behavior_tree_actions(&tree, ®istry, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_behavior_tree_invalid_action() { // Create a registry with some actions (but not "unknown_action") let mut registry = HashSet::new(); registry.insert("walk".to_string()); registry.insert("work".to_string()); let tree = Behavior { name: "Test".to_string(), root: BehaviorNode::Action("unknown_action".to_string(), vec![]), span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_behavior_tree_actions(&tree, ®istry, &mut collector); assert!(collector.has_errors()); } }