1use anyhow::{anyhow, Result};
2use duration_str::parse;
3use serde::{Deserialize, Deserializer, Serialize};
4use std::collections::HashMap;
5
6use crate::{event::Event, helpers::Connection, Formalize};
7
8#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)]
9pub struct Script {
10 #[serde(default, alias = "Name", alias = "NAME")]
11 pub name: Option<String>,
12 #[serde(
13 deserialize_with = "deserialize_string_to_u64",
14 rename = "start-time",
15 alias = "Start-time",
16 alias = "START-TIME"
17 )]
18 pub start_time: u64,
19 #[serde(
20 deserialize_with = "deserialize_string_to_u64",
21 rename = "end-time",
22 alias = "End-time",
23 alias = "END-TIME"
24 )]
25 pub end_time: u64,
26 #[serde(alias = "Speed", alias = "SPEED")]
27 pub speed: f32,
28 #[serde(
29 deserialize_with = "deserialize_events",
30 alias = "Events",
31 alias = "EVENTS"
32 )]
33 pub events: HashMap<String, u64>,
34 #[serde(alias = "Description", alias = "DESCRIPTION")]
35 pub description: Option<String>,
36}
37
38pub type Scripts = HashMap<String, Script>;
39
40impl Formalize for Script {
41 fn formalize(&mut self) -> Result<()> {
42 if self.events.is_empty() {
43 return Err(anyhow!("Script must have have at least one Event"));
44 } else if self.start_time > self.end_time {
45 return Err(anyhow!("Scripts end-time must be greater than start-time"));
46 }
47
48 for event in self.events.values() {
49 if *event < self.start_time {
50 return Err(anyhow!(
51 "Event time must be greater than or equal to script start time"
52 ));
53 } else if *event > self.end_time {
54 return Err(anyhow!(
55 "Event time must be less than or equal to script end time"
56 ));
57 }
58 }
59
60 if self.speed.is_sign_negative() {
61 return Err(anyhow!("Scripts speed must have a positive value"));
62 }
63
64 Ok(())
65 }
66}
67
68impl Connection<Event> for (&String, &Script) {
69 fn validate_connections(&self, potential_event_names: &Option<Vec<String>>) -> Result<()> {
70 if potential_event_names.is_none() {
71 return Err(anyhow!(
72 "Script \"{script_name}\" requires at least one Event but none found under Scenario",
73 script_name = self.0
74 ));
75 };
76
77 if let Some(event_names) = potential_event_names {
78 for event_name in self.1.events.keys() {
79 if !event_names.contains(event_name) {
80 return Err(anyhow!(
81 "Event \"{event_name}\" not found under Scenario Events"
82 ));
83 }
84 }
85 }
86
87 Ok(())
88 }
89}
90
91fn parse_time_string_to_u64_sec(mut string: String) -> Result<u64> {
92 if string.eq("0") {
93 string = String::from("0sec");
94 }
95
96 string.retain(|char| !char.is_whitespace() && char != '_');
97
98 let duration = parse(&string)?;
99 Ok(duration.as_secs())
100}
101
102fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
103where
104 D: Deserializer<'de>,
105{
106 let string = String::deserialize(deserializer)?;
107 let duration = parse_time_string_to_u64_sec(string)
108 .map_err(|_| serde::de::Error::custom("failed to parse str to duration"))?;
109 Ok(duration)
110}
111
112fn deserialize_events<'de, D>(deserializer: D) -> Result<HashMap<String, u64>, D::Error>
113where
114 D: Deserializer<'de>,
115{
116 let mut event_map: HashMap<String, String> = HashMap::deserialize(deserializer)?;
117
118 let output = event_map
119 .drain()
120 .map(|(key, string)| {
121 let duration = parse_time_string_to_u64_sec(string)
122 .map_err(|_| serde::de::Error::custom("failed to parse str to duration"))?;
123 Ok((key, duration))
124 })
125 .collect::<Result<HashMap<String, u64>, D::Error>>()?;
126
127 Ok(output)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::parse_sdl;
134
135 #[test]
136 fn parses_sdl_with_scripts() {
137 let sdl = r#"
138 name: test-scenario
139 description: some description
140 conditions:
141 condition-1:
142 command: executable/path.sh
143 interval: 30
144 scripts:
145 my-cool-script:
146 start-time: 10min 2 sec
147 end-time: 1 week 1day 1h 10 ms
148 speed: 1.5
149 events:
150 my-cool-event: 30 min
151 injects:
152 my-cool-inject:
153 source: inject-package
154 events:
155 my-cool-event:
156 conditions:
157 - condition-1
158 injects:
159 - my-cool-inject
160 "#;
161 let schema = parse_sdl(sdl).unwrap();
162
163 insta::with_settings!({sort_maps => true}, {
164 insta::assert_yaml_snapshot!(schema);
165 });
166 }
167
168 #[test]
169 fn parses_single_script() {
170 let script = r#"
171 start-time: 5h 10min 2sec
172 end-time: 1 week 7d 3 hour 10 ms
173 speed: 1
174 events:
175 my-cool-event: 6h 30min
176 "#;
177 serde_yaml::from_str::<Script>(script).unwrap();
178 }
179
180 #[test]
181 #[should_panic(expected = "Scripts end-time must be greater than start-time")]
182 fn fails_end_time_larger_than_start_time() {
183 let script = r#"
184 start-time: 1 year 5h 10min 2sec
185 end-time: 1 week 7d 3 hour 10 ms
186 speed: 1
187 events:
188 my-cool-event: 6h 30min
189 "#;
190 serde_yaml::from_str::<Script>(script)
191 .unwrap()
192 .formalize()
193 .unwrap();
194 }
195
196 #[test]
197 fn parses_zero_without_unit() {
198 let script = r#"
199 start-time: 0
200 end-time: 1 week 7d 3 hour 10 ms
201 speed: 1
202 events:
203 my-cool-event: 0
204 "#;
205 serde_yaml::from_str::<Script>(script)
206 .unwrap()
207 .formalize()
208 .unwrap();
209 }
210
211 #[test]
212 fn parses_underscore_formatted_numbers() {
213 let script = r#"
214 start-time: _1_0__0__ min
215 end-time: 1_000_000s
216 speed: 1
217 events:
218 my-cool-event: 1_2_0 min
219 "#;
220
221 let script = serde_yaml::from_str::<Script>(script).unwrap();
222
223 assert_eq!(script.start_time, 6000);
224 assert_eq!(script.end_time, 1000000);
225 assert_eq!(script.events["my-cool-event"], 7200);
226 }
227
228 #[test]
229 #[should_panic(expected = "Scripts speed must have a positive value")]
230 fn fails_on_negative_speed_value() {
231 let script = r#"
232 start-time: 0
233 end-time: 3 hour
234 speed: -1.234
235 events:
236 my-cool-event: 2 hour
237 "#;
238 serde_yaml::from_str::<Script>(script)
239 .unwrap()
240 .formalize()
241 .unwrap();
242 }
243
244 #[test]
245 #[should_panic(
246 expected = "Condition must have Command and Interval or Source defined, not both"
247 )]
248 fn fails_on_event_not_defined_for_script() {
249 let sdl = r#"
250 name: test-scenario
251 description: some description
252 conditions:
253 condition-1:
254 command: executable/path.sh
255 interval: 30
256 source: digital-library-package
257 scripts:
258 my-cool-script:
259 start-time: 10min 2 sec
260 end-time: 1 week 1day 1h 10 ms
261 speed: 1.5
262 events:
263 my-cool-event: 20 min
264 "#;
265 parse_sdl(sdl).unwrap();
266 }
267
268 #[test]
269 #[should_panic(expected = "Event \"my-cool-event\" not found under Scenario Events")]
270 fn fails_on_missing_event_for_script() {
271 let sdl = r#"
272 name: test-scenario
273 description: some description
274 conditions:
275 condition-1:
276 command: executable/path.sh
277 interval: 30
278 scripts:
279 my-cool-script:
280 start-time: 10min 2 sec
281 end-time: 1 week 1day 1h 10 ms
282 speed: 1.5
283 events:
284 my-cool-event: 20 min
285 injects:
286 my-cool-inject:
287 source: inject-package
288 events:
289 my-embarrassing-event:
290 time: 0.2345678
291 conditions:
292 - condition-1
293 injects:
294 - my-cool-inject
295 "#;
296 parse_sdl(sdl).unwrap();
297 }
298}