diff --git a/src/commands/check.rs b/src/commands/check.rs index 589ffbd..21df326 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -118,11 +118,19 @@ pub fn run() -> CliResult { } for variable in &entity.scalar_vars { - if !variable.value_range_provider.is_empty() - && !fact_fields.contains(variable.value_range_provider.as_str()) + let provider = variable.value_range_provider.as_str(); + // A provider names a solution fact collection or a + // `Vec` field on the same entity, which gives + // each entity its own legal domain. + if !provider.is_empty() + && !fact_fields.contains(provider) + && !entity + .entity_value_ranges + .iter() + .any(|field| field == provider) { errors.push(format!( - "Entity '{}.{}' references missing fact collection '{}' via value_range_provider", + "Entity '{}.{}' references value_range_provider '{}', which is neither a fact collection nor a Vec field on the entity", entity.item_type, variable.field, variable.value_range_provider )); } diff --git a/src/commands/check_tests.rs b/src/commands/check_tests.rs index abbcd90..a58bbaa 100644 --- a/src/commands/check_tests.rs +++ b/src/commands/check_tests.rs @@ -258,6 +258,66 @@ impl Task { ); } +#[test] +fn check_accepts_entity_scoped_value_range_providers() { + let _cwd_guard = test_support::lock_cwd(); + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + let _dir_guard = CwdGuard::enter(tmp.path()); + write_project_layout(); + + fs::write( + "src/domain/task.rs", + r#"use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +#[planning_entity] +#[derive(Serialize, Deserialize)] +pub struct Task { + #[planning_id] + pub id: String, + pub allowed_resources: Vec, + // @solverforge:begin entity-variables + #[planning_variable(value_range_provider = "allowed_resources", allows_unassigned = true)] + pub resource_idx: Option, + // @solverforge:end entity-variables +} + +impl Task { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + allowed_resources: Vec::new(), + // @solverforge:begin entity-variable-init + resource_idx: None, + // @solverforge:end entity-variable-init + } + } +} +"#, + ) + .expect("failed to rewrite task"); + + run().expect("a Vec field on the entity is a valid value range"); + + fs::write( + "src/domain/task.rs", + fs::read_to_string("src/domain/task.rs") + .expect("failed to read task") + .replace( + "pub allowed_resources: Vec,", + "pub allowed_resources: Vec,", + ), + ) + .expect("failed to retype allowed_resources"); + + let err = run().expect_err("a non-index field is not a value range"); + let err = err.to_string(); + assert!( + err.contains("1 error(s), 0 warning(s)"), + "unexpected summary: {err}" + ); +} + #[test] fn check_rejects_stale_scalar_group_variable_targets() { let _cwd_guard = test_support::lock_cwd(); diff --git a/src/commands/generate_constraint/domain.rs b/src/commands/generate_constraint/domain.rs index becd746..44b7b81 100644 --- a/src/commands/generate_constraint/domain.rs +++ b/src/commands/generate_constraint/domain.rs @@ -31,6 +31,8 @@ pub(crate) struct ListVarInfo { pub(crate) struct EntityInfo { pub field_name: String, pub item_type: String, + /// `Vec` fields that can serve as this entity's own value range. + pub entity_value_ranges: Vec, pub scalar_vars: Vec, pub list_vars: Vec, } @@ -81,6 +83,7 @@ struct SolutionInfo { } struct EntityStructInfo { + entity_value_ranges: Vec, scalar_vars: Vec, list_vars: Vec, } @@ -342,6 +345,7 @@ fn collect_domain_model( domain_entities.push(EntityInfo { field_name: field_name.clone(), item_type: collection_type.to_string(), + entity_value_ranges: entity.entity_value_ranges.clone(), scalar_vars: entity.scalar_vars.clone(), list_vars: entity.list_vars.clone(), }); @@ -439,10 +443,16 @@ fn parse_solution(item_struct: &ItemStruct) -> Result { fn parse_entity(item_struct: &ItemStruct) -> Result { let fields = named_fields(item_struct, "#[planning_entity] requires named fields")?; + let mut entity_value_ranges = Vec::new(); let mut scalar_vars = Vec::new(); let mut list_vars = Vec::new(); for field in fields { + if collection_type_name(&field.ty).as_deref() == Some("usize") { + if let Some(field_ident) = field.ident.as_ref() { + entity_value_ranges.push(field_ident.to_string()); + } + } if has_attribute(&field.attrs, "planning_variable") { if !field_is_option_usize(&field.ty) { continue; @@ -482,6 +492,7 @@ fn parse_entity(item_struct: &ItemStruct) -> Result { } Ok(EntityStructInfo { + entity_value_ranges, scalar_vars, list_vars, }) diff --git a/src/commands/generate_constraint/tests.rs b/src/commands/generate_constraint/tests.rs index c71a502..f6e00fc 100644 --- a/src/commands/generate_constraint/tests.rs +++ b/src/commands/generate_constraint/tests.rs @@ -481,6 +481,7 @@ fn test_generate_skeleton_unary_hard() { scalar_groups_path: None, conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "shifts".to_string(), item_type: "Shift".to_string(), scalar_vars: vec![ScalarVarInfo { @@ -541,6 +542,7 @@ fn test_generate_skeleton_pair_hard() { scalar_groups_path: None, conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "shifts".to_string(), item_type: "Shift".to_string(), scalar_vars: vec![ScalarVarInfo { @@ -582,6 +584,7 @@ fn test_generate_skeleton_join_hard() { scalar_groups_path: None, conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "shifts".to_string(), item_type: "Shift".to_string(), scalar_vars: vec![ScalarVarInfo { @@ -632,6 +635,7 @@ fn test_generate_skeleton_balance_soft() { scalar_groups_path: None, conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "shifts".to_string(), item_type: "Shift".to_string(), scalar_vars: vec![ScalarVarInfo { @@ -669,6 +673,7 @@ fn test_generate_skeleton_reward_soft_is_compile_safe() { scalar_groups_path: None, conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "shifts".to_string(), item_type: "Shift".to_string(), scalar_vars: vec![ScalarVarInfo { @@ -819,6 +824,7 @@ fn grouped_skeleton_domain() -> DomainModel { scalar_groups_path: None, conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "shifts".to_string(), item_type: "Shift".to_string(), scalar_vars: vec![ScalarVarInfo { diff --git a/src/model_contract.rs b/src/model_contract.rs index 67c792f..df932f8 100644 --- a/src/model_contract.rs +++ b/src/model_contract.rs @@ -808,6 +808,7 @@ mod tests { scalar_groups_path: Some("scalar_groups".to_string()), conflict_repairs_path: None, entities: vec![EntityInfo { + entity_value_ranges: Vec::new(), field_name: "tasks".to_string(), item_type: "Task".to_string(), scalar_vars: vec![ScalarVarInfo {