solverforge_core/domain/
model.rs

1use super::DomainClass;
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    pub fn to_dto(&self) -> indexmap::IndexMap<String, crate::solver::DomainObjectDto> {
43        use crate::domain::PlanningAnnotation as DomainAnnotation;
44        use crate::solver::{
45            DomainAccessor, DomainObjectDto, DomainObjectMapper, FieldDescriptor,
46            PlanningAnnotation as SolverAnnotation,
47        };
48
49        let mut result = indexmap::IndexMap::new();
50
51        for (name, class) in &self.classes {
52            let mut dto = DomainObjectDto::new();
53
54            for field in &class.fields {
55                // Generate accessor names that match WasmModuleBuilder exports:
56                // get_{Class}_{field} and set_{Class}_{field}
57                let (getter, setter) = if let Some(a) = &field.accessor {
58                    // Use explicitly defined accessor
59                    (a.getter.clone(), Some(a.setter.clone()))
60                } else {
61                    // Generate defaults
62                    let getter = format!("get_{}_{}", name, field.name);
63                    // Generate setter for fields that need to be modified:
64                    // - PlanningVariable: solver assigns values
65                    // - PlanningListVariable: solver modifies lists
66                    // - ProblemFactCollectionProperty: solution class collections
67                    // - PlanningEntityCollectionProperty: solution class entity collections
68                    let setter = if field.planning_annotations.iter().any(|a| {
69                        matches!(
70                            a,
71                            DomainAnnotation::PlanningVariable { .. }
72                                | DomainAnnotation::PlanningListVariable { .. }
73                                | DomainAnnotation::ProblemFactCollectionProperty
74                                | DomainAnnotation::PlanningEntityCollectionProperty
75                        )
76                    }) {
77                        Some(format!("set_{}_{}", name, field.name))
78                    } else {
79                        None
80                    };
81                    (getter, setter)
82                };
83
84                let accessor = if let Some(s) = setter {
85                    DomainAccessor::getter_setter(getter, s)
86                } else {
87                    DomainAccessor::new(getter)
88                };
89
90                // Convert domain annotations to solver annotations
91                let mut annotations = Vec::new();
92                for ann in &field.planning_annotations {
93                    match ann {
94                        DomainAnnotation::PlanningId => {
95                            annotations.push(SolverAnnotation::PlanningId);
96                        }
97                        DomainAnnotation::PlanningVariable {
98                            allows_unassigned, ..
99                        } => {
100                            annotations.push(SolverAnnotation::PlanningVariable {
101                                allows_unassigned: *allows_unassigned,
102                            });
103                        }
104                        DomainAnnotation::PlanningListVariable { .. } => {
105                            // List variables use the same PlanningVariable annotation
106                            annotations.push(SolverAnnotation::PlanningVariable {
107                                allows_unassigned: false,
108                            });
109                        }
110                        DomainAnnotation::PlanningScore { .. } => {
111                            annotations.push(SolverAnnotation::PlanningScore);
112                        }
113                        DomainAnnotation::ValueRangeProvider { .. } => {
114                            annotations.push(SolverAnnotation::ValueRangeProvider);
115                        }
116                        DomainAnnotation::ProblemFactCollectionProperty => {
117                            annotations.push(SolverAnnotation::ProblemFactCollectionProperty);
118                        }
119                        DomainAnnotation::PlanningEntityCollectionProperty => {
120                            annotations.push(SolverAnnotation::PlanningEntityCollectionProperty);
121                        }
122                        _ => {}
123                    }
124                }
125
126                // Derive field type from domain type
127                let field_type = field.field_type.to_type_string();
128
129                let field_descriptor = FieldDescriptor::new(field_type)
130                    .with_accessor(accessor)
131                    .with_annotations(annotations);
132
133                dto = dto.with_field(&field.name, field_descriptor);
134            }
135
136            // Add mapper for solution class (PlanningSolution)
137            // Uses parseSchedule/scheduleString which are exported by WasmModuleBuilder
138            if class.is_planning_solution() {
139                dto = dto.with_mapper(DomainObjectMapper::new("parseSchedule", "scheduleString"));
140            }
141
142            result.insert(name.clone(), dto);
143        }
144
145        result
146    }
147
148    pub fn validate(&self) -> Result<(), SolverForgeError> {
149        if self.solution_class.is_none() {
150            return Err(SolverForgeError::Validation(
151                "Domain model must have a solution class".to_string(),
152            ));
153        }
154
155        let solution_name = self.solution_class.as_ref().unwrap();
156        let solution = self.classes.get(solution_name).ok_or_else(|| {
157            SolverForgeError::Validation(format!(
158                "Solution class '{}' not found in domain model",
159                solution_name
160            ))
161        })?;
162
163        if !solution.is_planning_solution() {
164            return Err(SolverForgeError::Validation(format!(
165                "Class '{}' is marked as solution but lacks @PlanningSolution annotation",
166                solution_name
167            )));
168        }
169
170        if solution.get_score_field().is_none() {
171            return Err(SolverForgeError::Validation(format!(
172                "Solution class '{}' must have a @PlanningScore field",
173                solution_name
174            )));
175        }
176
177        if self.entity_classes.is_empty() {
178            return Err(SolverForgeError::Validation(
179                "Domain model must have at least one entity class".to_string(),
180            ));
181        }
182
183        for entity_name in &self.entity_classes {
184            let entity = self.classes.get(entity_name).ok_or_else(|| {
185                SolverForgeError::Validation(format!(
186                    "Entity class '{}' not found in domain model",
187                    entity_name
188                ))
189            })?;
190
191            if !entity.is_planning_entity() {
192                return Err(SolverForgeError::Validation(format!(
193                    "Class '{}' is marked as entity but lacks @PlanningEntity annotation",
194                    entity_name
195                )));
196            }
197
198            let has_variable = entity.get_planning_variables().next().is_some();
199            if !has_variable {
200                return Err(SolverForgeError::Validation(format!(
201                    "Entity class '{}' must have at least one @PlanningVariable",
202                    entity_name
203                )));
204            }
205        }
206
207        Ok(())
208    }
209}
210
211#[derive(Debug, Default)]
212pub struct DomainModelBuilder {
213    classes: IndexMap<String, DomainClass>,
214    solution_class: Option<String>,
215    entity_classes: Vec<String>,
216}
217
218impl DomainModelBuilder {
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    pub fn add_class(mut self, class: DomainClass) -> Self {
224        let name = class.name.clone();
225
226        if class.is_planning_solution() {
227            self.solution_class = Some(name.clone());
228        }
229
230        if class.is_planning_entity() {
231            self.entity_classes.push(name.clone());
232        }
233
234        self.classes.insert(name, class);
235        self
236    }
237
238    pub fn with_solution(mut self, class_name: impl Into<String>) -> Self {
239        self.solution_class = Some(class_name.into());
240        self
241    }
242
243    pub fn with_entity(mut self, class_name: impl Into<String>) -> Self {
244        self.entity_classes.push(class_name.into());
245        self
246    }
247
248    pub fn solution_class(self, class_name: impl Into<String>) -> Self {
249        self.with_solution(class_name)
250    }
251
252    pub fn entity_class(self, class_name: impl Into<String>) -> Self {
253        self.with_entity(class_name)
254    }
255
256    pub fn build(self) -> DomainModel {
257        DomainModel {
258            classes: self.classes,
259            solution_class: self.solution_class,
260            entity_classes: self.entity_classes,
261        }
262    }
263
264    pub fn build_validated(self) -> Result<DomainModel, SolverForgeError> {
265        let model = self.build();
266        model.validate()?;
267        Ok(model)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::domain::{FieldDescriptor, FieldType, PlanningAnnotation, ScoreType};
275
276    fn create_lesson_entity() -> DomainClass {
277        DomainClass::new("Lesson")
278            .with_annotation(PlanningAnnotation::PlanningEntity)
279            .with_field(
280                FieldDescriptor::new(
281                    "id",
282                    FieldType::Primitive(crate::domain::PrimitiveType::String),
283                )
284                .with_planning_annotation(PlanningAnnotation::PlanningId),
285            )
286            .with_field(
287                FieldDescriptor::new("room", FieldType::object("Room")).with_planning_annotation(
288                    PlanningAnnotation::planning_variable(vec!["rooms".to_string()]),
289                ),
290            )
291    }
292
293    fn create_timetable_solution() -> DomainClass {
294        DomainClass::new("Timetable")
295            .with_annotation(PlanningAnnotation::PlanningSolution)
296            .with_field(
297                FieldDescriptor::new("lessons", FieldType::list(FieldType::object("Lesson")))
298                    .with_planning_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
299            )
300            .with_field(
301                FieldDescriptor::new("rooms", FieldType::list(FieldType::object("Room")))
302                    .with_planning_annotation(PlanningAnnotation::value_range_provider("rooms")),
303            )
304            .with_field(
305                FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
306                    .with_planning_annotation(PlanningAnnotation::planning_score()),
307            )
308    }
309
310    #[test]
311    fn test_builder_basic() {
312        let model = DomainModel::builder()
313            .add_class(create_lesson_entity())
314            .add_class(create_timetable_solution())
315            .build();
316
317        assert_eq!(model.classes.len(), 2);
318        assert_eq!(model.solution_class, Some("Timetable".to_string()));
319        assert_eq!(model.entity_classes, vec!["Lesson"]);
320    }
321
322    #[test]
323    fn test_get_class() {
324        let model = DomainModel::builder()
325            .add_class(create_lesson_entity())
326            .build();
327
328        assert!(model.get_class("Lesson").is_some());
329        assert!(model.get_class("Unknown").is_none());
330    }
331
332    #[test]
333    fn test_get_solution_class() {
334        let model = DomainModel::builder()
335            .add_class(create_timetable_solution())
336            .build();
337
338        let solution = model.get_solution_class().unwrap();
339        assert_eq!(solution.name, "Timetable");
340    }
341
342    #[test]
343    fn test_get_entity_classes() {
344        let model = DomainModel::builder()
345            .add_class(create_lesson_entity())
346            .build();
347
348        let entities: Vec<_> = model.get_entity_classes().collect();
349        assert_eq!(entities.len(), 1);
350        assert_eq!(entities[0].name, "Lesson");
351    }
352
353    #[test]
354    fn test_validate_success() {
355        let model = DomainModel::builder()
356            .add_class(create_lesson_entity())
357            .add_class(create_timetable_solution())
358            .build();
359
360        assert!(model.validate().is_ok());
361    }
362
363    #[test]
364    fn test_validate_no_solution() {
365        let model = DomainModel::builder()
366            .add_class(create_lesson_entity())
367            .build();
368
369        let err = model.validate().unwrap_err();
370        assert!(err.to_string().contains("solution class"));
371    }
372
373    #[test]
374    fn test_validate_no_entities() {
375        let model = DomainModel::builder()
376            .add_class(create_timetable_solution())
377            .build();
378
379        let err = model.validate().unwrap_err();
380        assert!(err.to_string().contains("entity class"));
381    }
382
383    #[test]
384    fn test_validate_solution_without_score() {
385        let solution =
386            DomainClass::new("Timetable").with_annotation(PlanningAnnotation::PlanningSolution);
387
388        let model = DomainModel::builder()
389            .add_class(solution)
390            .add_class(create_lesson_entity())
391            .build();
392
393        let err = model.validate().unwrap_err();
394        assert!(err.to_string().contains("@PlanningScore"));
395    }
396
397    #[test]
398    fn test_validate_entity_without_variable() {
399        let entity = DomainClass::new("Lesson")
400            .with_annotation(PlanningAnnotation::PlanningEntity)
401            .with_field(
402                FieldDescriptor::new(
403                    "id",
404                    FieldType::Primitive(crate::domain::PrimitiveType::String),
405                )
406                .with_planning_annotation(PlanningAnnotation::PlanningId),
407            );
408
409        let model = DomainModel::builder()
410            .add_class(entity)
411            .add_class(create_timetable_solution())
412            .build();
413
414        let err = model.validate().unwrap_err();
415        assert!(err.to_string().contains("@PlanningVariable"));
416    }
417
418    #[test]
419    fn test_build_validated() {
420        let result = DomainModel::builder()
421            .add_class(create_lesson_entity())
422            .add_class(create_timetable_solution())
423            .build_validated();
424
425        assert!(result.is_ok());
426    }
427
428    #[test]
429    fn test_json_serialization() {
430        let model = DomainModel::builder()
431            .add_class(create_lesson_entity())
432            .add_class(create_timetable_solution())
433            .build();
434
435        let json = serde_json::to_string(&model).unwrap();
436        let parsed: DomainModel = serde_json::from_str(&json).unwrap();
437
438        assert_eq!(parsed.classes.len(), model.classes.len());
439        assert_eq!(parsed.solution_class, model.solution_class);
440    }
441}