//! 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::Decimal(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::Number(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::Decimal(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 participant fields for participant in &rel.participants { validate_trait_ranges(&participant.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.name.as_ref().unwrap_or(&block1.activity), block1.start.hour, block1.start.minute, block1.end.hour, block1.end.minute ), block2: format!( "{} ({}:{:02}-{}:{:02})", block2.name.as_ref().unwrap_or(&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 { child, .. } => { validate_tree_node_actions(child, action_registry, tree_name, collector); }, | BehaviorNode::SubTree(_path) => { // SubTree references validated separately }, } } /// Validate character resource linking /// /// Checks: /// 1. No duplicate behavior tree references /// 2. Priority values are valid (handled by type system) pub fn validate_character_resource_links(character: &Character, collector: &mut ErrorCollector) { // Check for duplicate behavior tree references if let Some(ref behavior_links) = character.uses_behaviors { let mut seen_trees: HashSet = HashSet::new(); for link in behavior_links { let tree_name = link.tree.join("::"); if seen_trees.contains(&tree_name) { collector.add(ResolveError::ValidationError { message: format!( "Character '{}' has duplicate behavior tree reference: {}", character.name, tree_name ), help: Some(format!( "The behavior tree '{}' is referenced multiple times in the uses behaviors list. Each behavior tree should only be referenced once. If you want different conditions or priorities, combine them into a single entry.", tree_name )), }); } seen_trees.insert(tree_name); } } // Check for duplicate schedule references if let Some(ref schedules) = character.uses_schedule { let mut seen_schedules: HashSet = HashSet::new(); for schedule in schedules { if seen_schedules.contains(schedule) { collector.add(ResolveError::ValidationError { message: format!( "Character '{}' has duplicate schedule reference: {}", character.name, schedule ), help: Some(format!( "The schedule '{}' is referenced multiple times. Each schedule should only be referenced once.", schedule )), }); } seen_schedules.insert(schedule.clone()); } } } /// Validate institution resource linking pub fn validate_institution_resource_links( institution: &Institution, collector: &mut ErrorCollector, ) { // Check for duplicate behavior tree references if let Some(ref behavior_links) = institution.uses_behaviors { let mut seen_trees: HashSet = HashSet::new(); for link in behavior_links { let tree_name = link.tree.join("::"); if seen_trees.contains(&tree_name) { collector.add(ResolveError::ValidationError { message: format!( "Institution '{}' has duplicate behavior tree reference: {}", institution.name, tree_name ), help: Some(format!( "The behavior tree '{}' is referenced multiple times. Each behavior tree should only be referenced once.", tree_name )), }); } seen_trees.insert(tree_name); } } // Check for duplicate schedule references if let Some(ref schedules) = institution.uses_schedule { let mut seen_schedules: HashSet = HashSet::new(); for schedule in schedules { if seen_schedules.contains(schedule) { collector.add(ResolveError::ValidationError { message: format!( "Institution '{}' has duplicate schedule reference: {}", institution.name, schedule ), help: Some(format!( "The schedule '{}' is referenced multiple times.", schedule )), }); } seen_schedules.insert(schedule.clone()); } } } /// Validate schedule composition requirements /// /// Checks: /// 1. All blocks in extended schedules have names /// 2. Override blocks reference valid block names (requires name resolution) pub fn validate_schedule_composition(schedule: &Schedule, collector: &mut ErrorCollector) { // If schedule extends another, all blocks must have names for override system if schedule.extends.is_some() { for block in &schedule.blocks { if block.name.is_none() && !block.activity.is_empty() { collector.add(ResolveError::ValidationError { message: format!( "Schedule '{}' extends another schedule but has unnamed blocks", schedule.name ), help: Some( "When a schedule extends another, all blocks must have names to support the override system. Use 'block name { ... }' syntax instead of 'time -> time : activity { ... }'.".to_string() ), }); } } } // Validate that new-style blocks have action references for block in &schedule.blocks { if block.name.is_some() && block.action.is_none() && block.activity.is_empty() { collector.add(ResolveError::ValidationError { message: format!( "Schedule '{}' block '{}' missing action reference", schedule.name, block.name.as_ref().unwrap() ), help: Some( "Named blocks should specify a behavior using 'action: BehaviorName'. Example: 'block work { 9:00 -> 17:00, action: WorkBehavior }'".to_string() ), }); } } } /// 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); validate_character_resource_links(c, &mut collector); }, | Declaration::Institution(i) => { validate_institution_resource_links(i, &mut collector); }, | Declaration::Relationship(r) => { validate_relationship_bonds(std::slice::from_ref(r), &mut collector); }, | Declaration::Schedule(s) => { validate_schedule_overlaps(s, &mut collector); validate_schedule_composition(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::Decimal(0.8), span: Span::new(0, 10), }, Field { name: "age".to_string(), value: Value::Number(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::Decimal(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::Decimal(-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::Number(-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::Number(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::Decimal(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::Decimal(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 { label: None, children: 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()); } }