1use super::{DomainClass, FieldType, PlanningAnnotation};
2use crate::SolverForgeError;
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct DomainModel {
8 pub classes: IndexMap<String, DomainClass>,
9 pub solution_class: Option<String>,
10 pub entity_classes: Vec<String>,
11}
12
13impl DomainModel {
14 pub fn new() -> Self {
15 Self::default()
16 }
17
18 pub fn builder() -> DomainModelBuilder {
19 DomainModelBuilder::new()
20 }
21
22 pub fn get_class(&self, name: &str) -> Option<&DomainClass> {
23 self.classes.get(name)
24 }
25
26 pub fn get_solution_class(&self) -> Option<&DomainClass> {
27 self.solution_class
28 .as_ref()
29 .and_then(|name| self.classes.get(name))
30 }
31
32 pub fn get_entity_classes(&self) -> impl Iterator<Item = &DomainClass> {
33 self.entity_classes
34 .iter()
35 .filter_map(|name| self.classes.get(name))
36 }
37
38 pub fn solution_class(&self) -> Option<&str> {
39 self.solution_class.as_deref()
40 }
41
42 fn lookup_value_range_provider_element_type(&self, provider_id: &str) -> Option<String> {
44 for class in self.classes.values() {
45 for field in &class.fields {
46 for annotation in &field.annotations {
47 if let PlanningAnnotation::ValueRangeProvider { id } = annotation {
48 let effective_id = id.as_deref().unwrap_or(&field.name);
49 if effective_id == provider_id {
50 match &field.field_type {
52 FieldType::List { element_type }
53 | FieldType::Array { element_type }
54 | FieldType::Set { element_type } => {
55 return Some(element_type.to_type_string());
56 }
57 _ => return Some(field.field_type.to_type_string()),
59 }
60 }
61 }
62 }
63 }
64 }
65 None
66 }
67
68 pub fn to_dto(&self) -> indexmap::IndexMap<String, crate::solver::DomainObjectDto> {
69 use crate::solver::{DomainAccessor, DomainObjectDto, DomainObjectMapper, FieldDescriptor};
70
71 let mut result = indexmap::IndexMap::new();
72
73 for (name, class) in &self.classes {
74 let mut dto = DomainObjectDto::new();
75
76 for field in &class.fields {
77 let (getter, setter) = if let Some(a) = &field.accessor {
80 (a.getter.clone(), Some(a.setter.clone()))
82 } else {
83 let getter = format!("get_{}_{}", name, field.name);
85 let setter = if field.annotations.iter().any(|a| {
93 matches!(
94 a,
95 PlanningAnnotation::PlanningVariable { .. }
96 | PlanningAnnotation::PlanningListVariable { .. }
97 | PlanningAnnotation::ProblemFactCollectionProperty
98 | PlanningAnnotation::PlanningEntityCollectionProperty
99 | PlanningAnnotation::InverseRelationShadowVariable { .. }
100 | PlanningAnnotation::PreviousElementShadowVariable { .. }
101 | PlanningAnnotation::NextElementShadowVariable { .. }
102 | PlanningAnnotation::CascadingUpdateShadowVariable { .. }
103 )
104 }) {
105 Some(format!("set_{}_{}", name, field.name))
106 } else {
107 None
108 };
109 (getter, setter)
110 };
111
112 let accessor = if let Some(s) = setter {
113 DomainAccessor::getter_setter(getter, s)
114 } else {
115 DomainAccessor::new(getter)
116 };
117
118 let field_type = if let Some(provider_refs) =
120 field.annotations.iter().find_map(|a| {
121 if let PlanningAnnotation::PlanningListVariable {
122 value_range_provider_refs,
123 ..
124 } = a
125 {
126 if !value_range_provider_refs.is_empty() {
127 return Some(value_range_provider_refs);
128 }
129 }
130 None
131 }) {
132 let provider_id = &provider_refs[0];
134 let element_type = self
135 .lookup_value_range_provider_element_type(provider_id)
136 .unwrap_or_else(|| {
137 panic!("Value range provider '{}' not found", provider_id)
138 });
139 format!("{}[]", element_type)
140 } else {
141 field.field_type.to_type_string()
143 };
144
145 let field_descriptor = FieldDescriptor::new(field_type)
146 .with_accessor(accessor)
147 .with_annotations(field.annotations.clone());
148
149 dto = dto.with_field(&field.name, field_descriptor);
150 }
151
152 if class.is_planning_entity() {
154 dto = dto.with_annotation(crate::solver::ClassAnnotation::PlanningEntity);
155 }
156 if class.is_planning_solution() {
157 dto = dto.with_annotation(crate::solver::ClassAnnotation::PlanningSolution);
158 }
159
160 if class.is_planning_solution() {
163 dto = dto.with_mapper(DomainObjectMapper::new("parseSchedule", "scheduleString"));
164 }
165
166 result.insert(name.clone(), dto);
167 }
168
169 result
170 }
171
172 pub fn validate(&self) -> Result<(), SolverForgeError> {
173 if self.solution_class.is_none() {
174 return Err(SolverForgeError::Validation(
175 "Domain model must have a solution class".to_string(),
176 ));
177 }
178
179 let solution_name = self.solution_class.as_ref().unwrap();
180 let solution = self.classes.get(solution_name).ok_or_else(|| {
181 SolverForgeError::Validation(format!(
182 "Solution class '{}' not found in domain model",
183 solution_name
184 ))
185 })?;
186
187 if !solution.is_planning_solution() {
188 return Err(SolverForgeError::Validation(format!(
189 "Class '{}' is marked as solution but lacks @PlanningSolution annotation",
190 solution_name
191 )));
192 }
193
194 if solution.get_score_field().is_none() {
195 return Err(SolverForgeError::Validation(format!(
196 "Solution class '{}' must have a @PlanningScore field",
197 solution_name
198 )));
199 }
200
201 if self.entity_classes.is_empty() {
202 return Err(SolverForgeError::Validation(
203 "Domain model must have at least one entity class".to_string(),
204 ));
205 }
206
207 for entity_name in &self.entity_classes {
208 let entity = self.classes.get(entity_name).ok_or_else(|| {
209 SolverForgeError::Validation(format!(
210 "Entity class '{}' not found in domain model",
211 entity_name
212 ))
213 })?;
214
215 if !entity.is_planning_entity() {
216 return Err(SolverForgeError::Validation(format!(
217 "Class '{}' is marked as entity but lacks @PlanningEntity annotation",
218 entity_name
219 )));
220 }
221
222 let has_variable = entity.get_planning_variables().next().is_some();
223 if !has_variable {
224 return Err(SolverForgeError::Validation(format!(
225 "Entity class '{}' must have at least one @PlanningVariable",
226 entity_name
227 )));
228 }
229 }
230
231 Ok(())
232 }
233
234 pub fn set_cascading_expression(
247 &mut self,
248 class_name: &str,
249 field_name: &str,
250 expression: crate::wasm::Expression,
251 ) -> Result<(), SolverForgeError> {
252 let class = self.classes.get_mut(class_name).ok_or_else(|| {
253 SolverForgeError::Validation(format!("Class '{}' not found", class_name))
254 })?;
255
256 let field = class
257 .fields
258 .iter_mut()
259 .find(|f| f.name == field_name)
260 .ok_or_else(|| {
261 SolverForgeError::Validation(format!(
262 "Field '{}' not found in class '{}'",
263 field_name, class_name
264 ))
265 })?;
266
267 let found = field.annotations.iter_mut().any(|ann| {
268 if let PlanningAnnotation::CascadingUpdateShadowVariable {
269 compute_expression, ..
270 } = ann
271 {
272 *compute_expression = Some(expression.clone());
273 true
274 } else {
275 false
276 }
277 });
278
279 if found {
280 Ok(())
281 } else {
282 Err(SolverForgeError::Validation(format!(
283 "Field '{}' in class '{}' has no CascadingUpdateShadowVariable annotation",
284 field_name, class_name
285 )))
286 }
287 }
288}
289
290#[derive(Debug, Default)]
291pub struct DomainModelBuilder {
292 classes: IndexMap<String, DomainClass>,
293 solution_class: Option<String>,
294 entity_classes: Vec<String>,
295}
296
297impl DomainModelBuilder {
298 pub fn new() -> Self {
299 Self::default()
300 }
301
302 pub fn add_class(mut self, class: DomainClass) -> Self {
303 let name = class.name.clone();
304
305 if class.is_planning_solution() {
306 self.solution_class = Some(name.clone());
307 }
308
309 if class.is_planning_entity() {
310 self.entity_classes.push(name.clone());
311 }
312
313 self.classes.insert(name, class);
314 self
315 }
316
317 pub fn with_solution(mut self, class_name: impl Into<String>) -> Self {
318 self.solution_class = Some(class_name.into());
319 self
320 }
321
322 pub fn with_entity(mut self, class_name: impl Into<String>) -> Self {
323 self.entity_classes.push(class_name.into());
324 self
325 }
326
327 pub fn solution_class(self, class_name: impl Into<String>) -> Self {
328 self.with_solution(class_name)
329 }
330
331 pub fn entity_class(self, class_name: impl Into<String>) -> Self {
332 self.with_entity(class_name)
333 }
334
335 pub fn build(self) -> DomainModel {
336 DomainModel {
337 classes: self.classes,
338 solution_class: self.solution_class,
339 entity_classes: self.entity_classes,
340 }
341 }
342
343 pub fn build_validated(self) -> Result<DomainModel, SolverForgeError> {
344 let model = self.build();
345 model.validate()?;
346 Ok(model)
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::domain::{FieldDescriptor, FieldType, PlanningAnnotation, ScoreType};
354
355 fn create_lesson_entity() -> DomainClass {
356 DomainClass::new("Lesson")
357 .with_annotation(PlanningAnnotation::PlanningEntity)
358 .with_field(
359 FieldDescriptor::new(
360 "id",
361 FieldType::Primitive(crate::domain::PrimitiveType::String),
362 )
363 .with_annotation(PlanningAnnotation::PlanningId),
364 )
365 .with_field(
366 FieldDescriptor::new("room", FieldType::object("Room")).with_annotation(
367 PlanningAnnotation::planning_variable(vec!["rooms".to_string()]),
368 ),
369 )
370 }
371
372 fn create_timetable_solution() -> DomainClass {
373 DomainClass::new("Timetable")
374 .with_annotation(PlanningAnnotation::PlanningSolution)
375 .with_field(
376 FieldDescriptor::new("lessons", FieldType::list(FieldType::object("Lesson")))
377 .with_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
378 )
379 .with_field(
380 FieldDescriptor::new("rooms", FieldType::list(FieldType::object("Room")))
381 .with_annotation(PlanningAnnotation::value_range_provider_with_id("rooms")),
382 )
383 .with_field(
384 FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
385 .with_annotation(PlanningAnnotation::planning_score()),
386 )
387 }
388
389 #[test]
390 fn test_builder_basic() {
391 let model = DomainModel::builder()
392 .add_class(create_lesson_entity())
393 .add_class(create_timetable_solution())
394 .build();
395
396 assert_eq!(model.classes.len(), 2);
397 assert_eq!(model.solution_class, Some("Timetable".to_string()));
398 assert_eq!(model.entity_classes, vec!["Lesson"]);
399 }
400
401 #[test]
402 fn test_get_class() {
403 let model = DomainModel::builder()
404 .add_class(create_lesson_entity())
405 .build();
406
407 assert!(model.get_class("Lesson").is_some());
408 assert!(model.get_class("Unknown").is_none());
409 }
410
411 #[test]
412 fn test_get_solution_class() {
413 let model = DomainModel::builder()
414 .add_class(create_timetable_solution())
415 .build();
416
417 let solution = model.get_solution_class().unwrap();
418 assert_eq!(solution.name, "Timetable");
419 }
420
421 #[test]
422 fn test_get_entity_classes() {
423 let model = DomainModel::builder()
424 .add_class(create_lesson_entity())
425 .build();
426
427 let entities: Vec<_> = model.get_entity_classes().collect();
428 assert_eq!(entities.len(), 1);
429 assert_eq!(entities[0].name, "Lesson");
430 }
431
432 #[test]
433 fn test_validate_success() {
434 let model = DomainModel::builder()
435 .add_class(create_lesson_entity())
436 .add_class(create_timetable_solution())
437 .build();
438
439 assert!(model.validate().is_ok());
440 }
441
442 #[test]
443 fn test_validate_no_solution() {
444 let model = DomainModel::builder()
445 .add_class(create_lesson_entity())
446 .build();
447
448 let err = model.validate().unwrap_err();
449 assert!(err.to_string().contains("solution class"));
450 }
451
452 #[test]
453 fn test_validate_no_entities() {
454 let model = DomainModel::builder()
455 .add_class(create_timetable_solution())
456 .build();
457
458 let err = model.validate().unwrap_err();
459 assert!(err.to_string().contains("entity class"));
460 }
461
462 #[test]
463 fn test_validate_solution_without_score() {
464 let solution =
465 DomainClass::new("Timetable").with_annotation(PlanningAnnotation::PlanningSolution);
466
467 let model = DomainModel::builder()
468 .add_class(solution)
469 .add_class(create_lesson_entity())
470 .build();
471
472 let err = model.validate().unwrap_err();
473 assert!(err.to_string().contains("@PlanningScore"));
474 }
475
476 #[test]
477 fn test_validate_entity_without_variable() {
478 let entity = DomainClass::new("Lesson")
479 .with_annotation(PlanningAnnotation::PlanningEntity)
480 .with_field(
481 FieldDescriptor::new(
482 "id",
483 FieldType::Primitive(crate::domain::PrimitiveType::String),
484 )
485 .with_annotation(PlanningAnnotation::PlanningId),
486 );
487
488 let model = DomainModel::builder()
489 .add_class(entity)
490 .add_class(create_timetable_solution())
491 .build();
492
493 let err = model.validate().unwrap_err();
494 assert!(err.to_string().contains("@PlanningVariable"));
495 }
496
497 #[test]
498 fn test_build_validated() {
499 let result = DomainModel::builder()
500 .add_class(create_lesson_entity())
501 .add_class(create_timetable_solution())
502 .build_validated();
503
504 assert!(result.is_ok());
505 }
506
507 #[test]
508 fn test_json_serialization() {
509 let model = DomainModel::builder()
510 .add_class(create_lesson_entity())
511 .add_class(create_timetable_solution())
512 .build();
513
514 let json = serde_json::to_string(&model).unwrap();
515 let parsed: DomainModel = serde_json::from_str(&json).unwrap();
516
517 assert_eq!(parsed.classes.len(), model.classes.len());
518 assert_eq!(parsed.solution_class, model.solution_class);
519 }
520}