1use rust_decimal::Decimal;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use uuid::Uuid;
5
6use super::intervention::InterventionType;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Scenario {
11 pub id: Uuid,
12 pub name: String,
13 pub description: String,
14 #[serde(default)]
15 pub tags: Vec<String>,
16 pub base: Option<String>,
18 pub probability_weight: Option<f64>,
20 pub interventions: Vec<Intervention>,
21 #[serde(default)]
22 pub constraints: ScenarioConstraints,
23 #[serde(default)]
24 pub output: ScenarioOutputConfig,
25 #[serde(default)]
26 pub metadata: HashMap<String, String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Intervention {
32 pub id: Uuid,
33 pub intervention_type: InterventionType,
34 pub timing: InterventionTiming,
35 pub label: Option<String>,
37 #[serde(default)]
39 pub priority: u32,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct InterventionTiming {
45 pub start_month: u32,
47 pub duration_months: Option<u32>,
49 #[serde(default)]
51 pub onset: OnsetType,
52 pub ramp_months: Option<u32>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57#[serde(rename_all = "snake_case")]
58pub enum OnsetType {
59 #[default]
61 Sudden,
62 Gradual,
64 Oscillating,
66 Custom { easing: EasingFunction },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum EasingFunction {
73 Linear,
74 EaseIn,
75 EaseOut,
76 EaseInOut,
77 Step { steps: u32 },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ScenarioConstraints {
83 #[serde(default = "default_true")]
85 pub preserve_accounting_identity: bool,
86 #[serde(default = "default_true")]
88 pub preserve_document_chains: bool,
89 #[serde(default = "default_true")]
91 pub preserve_period_close: bool,
92 #[serde(default = "default_true")]
94 pub preserve_balance_coherence: bool,
95 #[serde(default)]
97 pub custom: Vec<CustomConstraint>,
98}
99
100impl Default for ScenarioConstraints {
101 fn default() -> Self {
102 Self {
103 preserve_accounting_identity: true,
104 preserve_document_chains: true,
105 preserve_period_close: true,
106 preserve_balance_coherence: true,
107 custom: Vec::new(),
108 }
109 }
110}
111
112fn default_true() -> bool {
113 true
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CustomConstraint {
118 pub config_path: String,
119 pub min: Option<Decimal>,
120 pub max: Option<Decimal>,
121 pub description: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ScenarioOutputConfig {
126 #[serde(default = "default_true")]
128 pub paired: bool,
129 #[serde(default = "default_diff_formats")]
131 pub diff_formats: Vec<DiffFormat>,
132 #[serde(default)]
134 pub diff_scope: Vec<String>,
135}
136
137impl Default for ScenarioOutputConfig {
138 fn default() -> Self {
139 Self {
140 paired: true,
141 diff_formats: default_diff_formats(),
142 diff_scope: Vec::new(),
143 }
144 }
145}
146
147fn default_diff_formats() -> Vec<DiffFormat> {
148 vec![DiffFormat::Summary, DiffFormat::Aggregate]
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum DiffFormat {
154 Summary,
156 RecordLevel,
158 Aggregate,
160 InterventionTrace,
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_scenario_constraints_default_all_true() {
170 let constraints = ScenarioConstraints::default();
171 assert!(constraints.preserve_accounting_identity);
172 assert!(constraints.preserve_document_chains);
173 assert!(constraints.preserve_period_close);
174 assert!(constraints.preserve_balance_coherence);
175 assert!(constraints.custom.is_empty());
176 }
177
178 #[test]
179 fn test_onset_type_variants() {
180 let onset: OnsetType = serde_json::from_str(r#""sudden""#).unwrap();
182 assert!(matches!(onset, OnsetType::Sudden));
183
184 let onset: OnsetType = serde_json::from_str(r#""gradual""#).unwrap();
185 assert!(matches!(onset, OnsetType::Gradual));
186
187 let onset: OnsetType = serde_json::from_str(r#""oscillating""#).unwrap();
188 assert!(matches!(onset, OnsetType::Oscillating));
189
190 let onset: OnsetType = serde_json::from_str(r#"{"custom":{"easing":"ease_in"}}"#).unwrap();
192 assert!(matches!(onset, OnsetType::Custom { .. }));
193 }
194
195 #[test]
196 fn test_scenario_serde_roundtrip() {
197 let scenario = Scenario {
198 id: Uuid::new_v4(),
199 name: "test_scenario".to_string(),
200 description: "A test scenario".to_string(),
201 tags: vec!["test".to_string()],
202 base: None,
203 probability_weight: Some(0.5),
204 interventions: vec![],
205 constraints: ScenarioConstraints::default(),
206 output: ScenarioOutputConfig::default(),
207 metadata: HashMap::new(),
208 };
209
210 let json = serde_json::to_string(&scenario).unwrap();
211 let deserialized: Scenario = serde_json::from_str(&json).unwrap();
212
213 assert_eq!(deserialized.name, "test_scenario");
214 assert_eq!(deserialized.probability_weight, Some(0.5));
215 assert!(deserialized.constraints.preserve_accounting_identity);
216 assert!(deserialized.output.paired);
217 }
218
219 #[test]
220 fn test_scenario_output_config_defaults() {
221 let config = ScenarioOutputConfig::default();
222 assert!(config.paired);
223 assert_eq!(config.diff_formats.len(), 2);
224 assert!(config.diff_formats.contains(&DiffFormat::Summary));
225 assert!(config.diff_formats.contains(&DiffFormat::Aggregate));
226 assert!(config.diff_scope.is_empty());
227 }
228
229 #[test]
230 fn test_diff_format_serde() {
231 let format: DiffFormat = serde_json::from_str(r#""summary""#).unwrap();
232 assert_eq!(format, DiffFormat::Summary);
233
234 let format: DiffFormat = serde_json::from_str(r#""record_level""#).unwrap();
235 assert_eq!(format, DiffFormat::RecordLevel);
236 }
237}