sdl_parser/
script.rs

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}