solverforge_core/domain/
class.rs

1use super::{PlanningAnnotation, ShadowAnnotation};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub struct DomainClass {
6    pub name: String,
7    #[serde(default)]
8    pub annotations: Vec<PlanningAnnotation>,
9    #[serde(default)]
10    pub fields: Vec<FieldDescriptor>,
11}
12
13impl DomainClass {
14    pub fn new(name: impl Into<String>) -> Self {
15        Self {
16            name: name.into(),
17            annotations: Vec::new(),
18            fields: Vec::new(),
19        }
20    }
21
22    pub fn with_annotation(mut self, annotation: PlanningAnnotation) -> Self {
23        self.annotations.push(annotation);
24        self
25    }
26
27    pub fn with_field(mut self, field: FieldDescriptor) -> Self {
28        self.fields.push(field);
29        self
30    }
31
32    pub fn is_planning_entity(&self) -> bool {
33        self.annotations
34            .iter()
35            .any(|a| matches!(a, PlanningAnnotation::PlanningEntity))
36    }
37
38    pub fn is_planning_solution(&self) -> bool {
39        self.annotations
40            .iter()
41            .any(|a| matches!(a, PlanningAnnotation::PlanningSolution))
42    }
43
44    pub fn get_planning_variables(&self) -> impl Iterator<Item = &FieldDescriptor> {
45        self.fields.iter().filter(|f| f.is_planning_variable())
46    }
47
48    pub fn get_planning_id_field(&self) -> Option<&FieldDescriptor> {
49        self.fields
50            .iter()
51            .find(|f| f.has_annotation(|a| matches!(a, PlanningAnnotation::PlanningId)))
52    }
53
54    pub fn get_score_field(&self) -> Option<&FieldDescriptor> {
55        self.fields
56            .iter()
57            .find(|f| f.has_annotation(|a| matches!(a, PlanningAnnotation::PlanningScore { .. })))
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct FieldDescriptor {
63    pub name: String,
64    pub field_type: FieldType,
65    #[serde(default)]
66    pub planning_annotations: Vec<PlanningAnnotation>,
67    #[serde(default)]
68    pub shadow_annotations: Vec<ShadowAnnotation>,
69    #[serde(default)]
70    pub accessor: Option<DomainAccessor>,
71}
72
73impl FieldDescriptor {
74    pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
75        Self {
76            name: name.into(),
77            field_type,
78            planning_annotations: Vec::new(),
79            shadow_annotations: Vec::new(),
80            accessor: None,
81        }
82    }
83
84    pub fn with_planning_annotation(mut self, annotation: PlanningAnnotation) -> Self {
85        self.planning_annotations.push(annotation);
86        self
87    }
88
89    pub fn with_shadow_annotation(mut self, annotation: ShadowAnnotation) -> Self {
90        self.shadow_annotations.push(annotation);
91        self
92    }
93
94    pub fn with_accessor(mut self, accessor: DomainAccessor) -> Self {
95        self.accessor = Some(accessor);
96        self
97    }
98
99    pub fn is_planning_variable(&self) -> bool {
100        self.planning_annotations
101            .iter()
102            .any(|a| a.is_any_variable())
103    }
104
105    pub fn is_shadow_variable(&self) -> bool {
106        !self.shadow_annotations.is_empty()
107    }
108
109    pub fn has_annotation<F>(&self, predicate: F) -> bool
110    where
111        F: Fn(&PlanningAnnotation) -> bool,
112    {
113        self.planning_annotations.iter().any(predicate)
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct DomainAccessor {
119    pub getter: String,
120    pub setter: String,
121}
122
123impl DomainAccessor {
124    pub fn new(getter: impl Into<String>, setter: impl Into<String>) -> Self {
125        Self {
126            getter: getter.into(),
127            setter: setter.into(),
128        }
129    }
130
131    pub fn from_field_name(field_name: &str) -> Self {
132        let capitalized = capitalize_first(field_name);
133        Self {
134            getter: format!("get{}", capitalized),
135            setter: format!("set{}", capitalized),
136        }
137    }
138}
139
140fn capitalize_first(s: &str) -> String {
141    let mut chars = s.chars();
142    match chars.next() {
143        None => String::new(),
144        Some(first) => first.to_uppercase().chain(chars).collect(),
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "kind")]
150pub enum FieldType {
151    Primitive(PrimitiveType),
152    Object {
153        class_name: String,
154    },
155    Array {
156        element_type: Box<FieldType>,
157    },
158    List {
159        element_type: Box<FieldType>,
160    },
161    Set {
162        element_type: Box<FieldType>,
163    },
164    Map {
165        key_type: Box<FieldType>,
166        value_type: Box<FieldType>,
167    },
168    Score(ScoreType),
169}
170
171impl FieldType {
172    pub fn object(class_name: impl Into<String>) -> Self {
173        FieldType::Object {
174            class_name: class_name.into(),
175        }
176    }
177
178    pub fn array(element_type: FieldType) -> Self {
179        FieldType::Array {
180            element_type: Box::new(element_type),
181        }
182    }
183
184    pub fn list(element_type: FieldType) -> Self {
185        FieldType::List {
186            element_type: Box::new(element_type),
187        }
188    }
189
190    pub fn set(element_type: FieldType) -> Self {
191        FieldType::Set {
192            element_type: Box::new(element_type),
193        }
194    }
195
196    pub fn map(key_type: FieldType, value_type: FieldType) -> Self {
197        FieldType::Map {
198            key_type: Box::new(key_type),
199            value_type: Box::new(value_type),
200        }
201    }
202
203    pub fn is_collection(&self) -> bool {
204        matches!(
205            self,
206            FieldType::Array { .. } | FieldType::List { .. } | FieldType::Set { .. }
207        )
208    }
209
210    /// Returns a Java-compatible type string for the field type
211    pub fn to_type_string(&self) -> String {
212        match self {
213            FieldType::Primitive(p) => match p {
214                PrimitiveType::Bool => "boolean".to_string(),
215                PrimitiveType::Int => "int".to_string(),
216                PrimitiveType::Long => "long".to_string(),
217                PrimitiveType::Float => "float".to_string(),
218                PrimitiveType::Double => "double".to_string(),
219                PrimitiveType::String => "String".to_string(),
220                PrimitiveType::Date => "LocalDate".to_string(),
221                PrimitiveType::DateTime => "LocalDateTime".to_string(),
222            },
223            FieldType::Object { class_name } => class_name.clone(),
224            FieldType::Array { element_type } => format!("{}[]", element_type.to_type_string()),
225            FieldType::List { element_type } => format!("{}[]", element_type.to_type_string()),
226            FieldType::Set { element_type } => format!("{}[]", element_type.to_type_string()),
227            FieldType::Map {
228                key_type,
229                value_type,
230            } => {
231                format!(
232                    "Map<{}, {}>",
233                    key_type.to_type_string(),
234                    value_type.to_type_string()
235                )
236            }
237            FieldType::Score(s) => match s {
238                ScoreType::Simple => "SimpleScore".to_string(),
239                ScoreType::HardSoft => "HardSoftScore".to_string(),
240                ScoreType::HardMediumSoft => "HardMediumSoftScore".to_string(),
241                ScoreType::SimpleDecimal => "SimpleBigDecimalScore".to_string(),
242                ScoreType::HardSoftDecimal => "HardSoftBigDecimalScore".to_string(),
243                ScoreType::HardMediumSoftDecimal => "HardMediumSoftBigDecimalScore".to_string(),
244                ScoreType::Bendable { .. } => "BendableScore".to_string(),
245                ScoreType::BendableDecimal { .. } => "BendableBigDecimalScore".to_string(),
246            },
247        }
248    }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252pub enum PrimitiveType {
253    Bool,
254    Int,
255    Long,
256    Float,
257    Double,
258    String,
259    Date,     // LocalDate - stored as epoch day (i64)
260    DateTime, // LocalDateTime - stored as epoch second (i64)
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub enum ScoreType {
265    Simple,
266    HardSoft,
267    HardMediumSoft,
268    SimpleDecimal,
269    HardSoftDecimal,
270    HardMediumSoftDecimal,
271    Bendable {
272        hard_levels: usize,
273        soft_levels: usize,
274    },
275    BendableDecimal {
276        hard_levels: usize,
277        soft_levels: usize,
278    },
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_domain_class_new() {
287        let class = DomainClass::new("Lesson");
288        assert_eq!(class.name, "Lesson");
289        assert!(class.annotations.is_empty());
290        assert!(class.fields.is_empty());
291    }
292
293    #[test]
294    fn test_domain_class_builder() {
295        let class = DomainClass::new("Lesson")
296            .with_annotation(PlanningAnnotation::PlanningEntity)
297            .with_field(
298                FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
299                    .with_planning_annotation(PlanningAnnotation::PlanningId),
300            )
301            .with_field(
302                FieldDescriptor::new("room", FieldType::object("Room")).with_planning_annotation(
303                    PlanningAnnotation::planning_variable(vec!["rooms".to_string()]),
304                ),
305            );
306
307        assert!(class.is_planning_entity());
308        assert!(!class.is_planning_solution());
309        assert_eq!(class.get_planning_variables().count(), 1);
310        assert!(class.get_planning_id_field().is_some());
311    }
312
313    #[test]
314    fn test_field_descriptor() {
315        let field = FieldDescriptor::new("room", FieldType::object("Room"))
316            .with_planning_annotation(PlanningAnnotation::planning_variable(vec![
317                "rooms".to_string()
318            ]))
319            .with_accessor(DomainAccessor::from_field_name("room"));
320
321        assert!(field.is_planning_variable());
322        assert!(!field.is_shadow_variable());
323        assert!(field.accessor.is_some());
324
325        let accessor = field.accessor.unwrap();
326        assert_eq!(accessor.getter, "getRoom");
327        assert_eq!(accessor.setter, "setRoom");
328    }
329
330    #[test]
331    fn test_shadow_field() {
332        let field = FieldDescriptor::new("vehicle", FieldType::object("Vehicle"))
333            .with_shadow_annotation(ShadowAnnotation::inverse_relation("visits"));
334
335        assert!(!field.is_planning_variable());
336        assert!(field.is_shadow_variable());
337    }
338
339    #[test]
340    fn test_field_type_object() {
341        let ft = FieldType::object("Room");
342        match ft {
343            FieldType::Object { class_name } => assert_eq!(class_name, "Room"),
344            _ => panic!("Expected Object"),
345        }
346    }
347
348    #[test]
349    fn test_field_type_collection() {
350        let list = FieldType::list(FieldType::object("Lesson"));
351        assert!(list.is_collection());
352
353        let obj = FieldType::object("Room");
354        assert!(!obj.is_collection());
355    }
356
357    #[test]
358    fn test_field_type_nested() {
359        let map = FieldType::map(
360            FieldType::Primitive(PrimitiveType::String),
361            FieldType::list(FieldType::object("Lesson")),
362        );
363
364        match map {
365            FieldType::Map {
366                key_type,
367                value_type,
368            } => {
369                assert!(matches!(
370                    *key_type,
371                    FieldType::Primitive(PrimitiveType::String)
372                ));
373                assert!(matches!(*value_type, FieldType::List { .. }));
374            }
375            _ => panic!("Expected Map"),
376        }
377    }
378
379    #[test]
380    fn test_score_type() {
381        let bendable = ScoreType::Bendable {
382            hard_levels: 2,
383            soft_levels: 3,
384        };
385        match bendable {
386            ScoreType::Bendable {
387                hard_levels,
388                soft_levels,
389            } => {
390                assert_eq!(hard_levels, 2);
391                assert_eq!(soft_levels, 3);
392            }
393            _ => panic!("Expected Bendable"),
394        }
395    }
396
397    #[test]
398    fn test_domain_accessor_from_field() {
399        let accessor = DomainAccessor::from_field_name("timeslot");
400        assert_eq!(accessor.getter, "getTimeslot");
401        assert_eq!(accessor.setter, "setTimeslot");
402    }
403
404    #[test]
405    fn test_solution_class() {
406        let solution = DomainClass::new("Timetable")
407            .with_annotation(PlanningAnnotation::PlanningSolution)
408            .with_field(
409                FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
410                    .with_planning_annotation(PlanningAnnotation::planning_score()),
411            );
412
413        assert!(solution.is_planning_solution());
414        assert!(solution.get_score_field().is_some());
415    }
416
417    #[test]
418    fn test_json_serialization() {
419        let class = DomainClass::new("Lesson")
420            .with_annotation(PlanningAnnotation::PlanningEntity)
421            .with_field(
422                FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
423                    .with_planning_annotation(PlanningAnnotation::PlanningId),
424            );
425
426        let json = serde_json::to_string(&class).unwrap();
427        let parsed: DomainClass = serde_json::from_str(&json).unwrap();
428        assert_eq!(parsed.name, class.name);
429        assert_eq!(parsed.fields.len(), class.fields.len());
430    }
431}