solverforge_core/domain/
model.rs1use 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 let (getter, setter) = if let Some(a) = &field.accessor {
58 (a.getter.clone(), Some(a.setter.clone()))
60 } else {
61 let getter = format!("get_{}_{}", name, field.name);
63 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 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 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 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 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}