1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct TimeCtx {
6 pub bpm: Option<f64>,
7 pub beat_offset: f64,
8 pub scene_start: f64,
9}
10
11impl Default for TimeCtx {
12 fn default() -> Self {
13 TimeCtx {
14 bpm: None,
15 beat_offset: 0.0,
16 scene_start: 0.0,
17 }
18 }
19}
20
21#[derive(Debug, Clone, PartialEq, thiserror::Error)]
22pub enum TimeError {
23 #[error("beat unit in `{0}` but the scenario declares no bpm")]
24 NoBpm(String),
25 #[error("cannot parse time `{0}`")]
26 Unparseable(String),
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
49#[serde(untagged)]
50pub enum TimePoint {
51 Seconds(f64),
54 Spec(String),
56}
57
58impl TimePoint {
59 pub fn resolve_relative(&self, ctx: &TimeCtx) -> Result<f64, TimeError> {
60 self.eval_sum(ctx)
61 }
62
63 pub fn resolve_absolute(&self, ctx: &TimeCtx) -> Result<f64, TimeError> {
64 let value = self.eval_sum(ctx)?;
65 if self.is_absolute() {
66 Ok(value)
67 } else {
68 Ok(ctx.scene_start + value)
69 }
70 }
71
72 pub fn is_absolute(&self) -> bool {
73 match self {
74 TimePoint::Seconds(_) => false,
75 TimePoint::Spec(spec) => spec.starts_with('@'),
76 }
77 }
78
79 fn eval_sum(&self, ctx: &TimeCtx) -> Result<f64, TimeError> {
80 match self {
81 TimePoint::Seconds(seconds) => Ok(*seconds),
82 TimePoint::Spec(spec) => eval_spec(spec, ctx),
83 }
84 }
85
86 pub fn validate_grammar(&self) -> Result<(), TimeError> {
87 match self {
88 TimePoint::Seconds(_) => Ok(()),
89 TimePoint::Spec(_) => {
90 let placeholder = TimeCtx {
91 bpm: Some(1.0),
92 beat_offset: 0.0,
93 scene_start: 0.0,
94 };
95 match self.eval_sum(&placeholder) {
96 Ok(_) => Ok(()),
97 Err(TimeError::NoBpm(_)) => {
98 unreachable!("the placeholder context always supplies a bpm")
99 }
100 Err(e @ TimeError::Unparseable(_)) => Err(e),
101 }
102 }
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum TimeUnit {
109 Seconds,
110 Millis,
111 Beats,
112}
113
114fn split_signed_terms(body: &str) -> Vec<(f64, String)> {
115 let mut terms = Vec::new();
116 let mut sign = 1.0;
117 let mut current = String::new();
118 for (i, ch) in body.char_indices() {
119 match ch {
120 '+' if i == 0 => {}
121 '-' if i == 0 => sign = -1.0,
122 '+' | '-' if i != 0 => {
123 terms.push((sign, std::mem::take(&mut current)));
124 sign = if ch == '-' { -1.0 } else { 1.0 };
125 }
126 _ => current.push(ch),
127 }
128 }
129 terms.push((sign, current));
130 terms
131}
132
133fn split_unit(term: &str) -> Option<(&str, TimeUnit)> {
134 if let Some(number) = term.strip_suffix("ms") {
135 Some((number, TimeUnit::Millis))
136 } else if let Some(number) = term.strip_suffix('s') {
137 Some((number, TimeUnit::Seconds))
138 } else if let Some(number) = term.strip_suffix('b') {
139 Some((number, TimeUnit::Beats))
140 } else {
141 None
142 }
143}
144
145fn eval_spec(original: &str, ctx: &TimeCtx) -> Result<f64, TimeError> {
146 let body = original.strip_prefix('@').unwrap_or(original);
147 if body.is_empty() {
148 return Err(TimeError::Unparseable(original.to_string()));
149 }
150
151 let mut total = 0.0;
152 for (sign, term) in split_signed_terms(body) {
153 let (number_str, unit) =
154 split_unit(&term).ok_or_else(|| TimeError::Unparseable(original.to_string()))?;
155 if number_str.is_empty() {
156 return Err(TimeError::Unparseable(original.to_string()));
157 }
158 let number: f64 = number_str
159 .parse()
160 .map_err(|_| TimeError::Unparseable(original.to_string()))?;
161
162 let term_value = match unit {
163 TimeUnit::Seconds => number,
164 TimeUnit::Millis => number / 1000.0,
165 TimeUnit::Beats => {
166 let bpm = ctx
167 .bpm
168 .ok_or_else(|| TimeError::NoBpm(original.to_string()))?;
169 ctx.beat_offset + number * 60.0 / bpm
170 }
171 };
172 total += sign * term_value;
173 }
174
175 Ok(total)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn ctx(bpm: Option<f64>, beat_offset: f64, scene_start: f64) -> TimeCtx {
183 TimeCtx {
184 bpm,
185 beat_offset,
186 scene_start,
187 }
188 }
189
190 #[test]
191 fn bare_number_deserializes_as_seconds() {
192 let tp: TimePoint = serde_json::from_str("2.5").unwrap();
193 assert_eq!(tp, TimePoint::Seconds(2.5));
194 }
195
196 #[test]
197 fn string_deserializes_as_spec() {
198 let tp: TimePoint = serde_json::from_str("\"2.5s\"").unwrap();
199 assert_eq!(tp, TimePoint::Spec("2.5s".to_string()));
200 }
201
202 #[test]
203 fn seconds_spec_resolves_plainly() {
204 let tp = TimePoint::Spec("2.5s".to_string());
205 let c = ctx(None, 0.0, 10.0);
206 assert_eq!(tp.resolve_relative(&c).unwrap(), 2.5);
207 assert_eq!(tp.resolve_absolute(&c).unwrap(), 12.5);
208 }
209
210 #[test]
211 fn millis_spec_resolves() {
212 let tp = TimePoint::Spec("120ms".to_string());
213 let c = ctx(None, 0.0, 0.0);
214 assert_eq!(tp.resolve_relative(&c).unwrap(), 0.12);
215 }
216
217 #[test]
218 fn beat_spec_resolves_with_beat_offset() {
219 let tp = TimePoint::Spec("8b".to_string());
220 let c = ctx(Some(120.0), 2.2, 0.0);
221 let expected = 2.2 + 8.0 * 60.0 / 120.0;
222 assert!((tp.resolve_relative(&c).unwrap() - expected).abs() < 1e-9);
223 }
224
225 #[test]
226 fn compound_beat_plus_millis_spec_resolves() {
227 let tp = TimePoint::Spec("8b+120ms".to_string());
228 let c = ctx(Some(120.0), 0.0, 0.0);
229 assert!((tp.resolve_relative(&c).unwrap() - 4.12).abs() < 1e-9);
230 }
231
232 #[test]
233 fn at_prefix_is_absolute_and_ignores_scene_start() {
234 let tp = TimePoint::Spec("@8b".to_string());
235 assert!(tp.is_absolute());
236 let c = ctx(Some(100.0), 2.2, 1000.0);
237 let expected = 2.2 + 8.0 * 60.0 / 100.0;
238 assert!((tp.resolve_relative(&c).unwrap() - expected).abs() < 1e-9);
239 assert!((tp.resolve_absolute(&c).unwrap() - expected).abs() < 1e-9);
240 }
241
242 #[test]
243 fn at_prefix_with_subtracted_beat_term() {
244 let tp = TimePoint::Spec("@2.2s-1b".to_string());
245 let c = ctx(Some(120.0), 0.0, 0.0);
246 assert!((tp.resolve_absolute(&c).unwrap() - 1.7).abs() < 1e-9);
247 }
248
249 #[test]
250 fn plain_seconds_variant_is_never_absolute() {
251 let tp = TimePoint::Seconds(3.0);
252 assert!(!tp.is_absolute());
253 let c = ctx(None, 0.0, 5.0);
254 assert_eq!(tp.resolve_relative(&c).unwrap(), 3.0);
255 assert_eq!(tp.resolve_absolute(&c).unwrap(), 8.0);
256 }
257
258 #[test]
259 fn beat_unit_without_bpm_errors() {
260 let tp = TimePoint::Spec("8b".to_string());
261 let c = ctx(None, 0.0, 0.0);
262 assert_eq!(
263 tp.resolve_relative(&c),
264 Err(TimeError::NoBpm("8b".to_string()))
265 );
266 }
267
268 #[test]
269 fn beat_unit_without_bpm_errors_even_nested_in_a_sum() {
270 let tp = TimePoint::Spec("120ms+8b".to_string());
271 let c = ctx(None, 0.0, 0.0);
272 assert_eq!(
273 tp.resolve_relative(&c),
274 Err(TimeError::NoBpm("120ms+8b".to_string()))
275 );
276 }
277
278 #[test]
279 fn malformed_spec_is_unparseable() {
280 for bad in ["", "abc", "5xyz", "s5", "5", "+", "-", "5s+"] {
281 let tp = TimePoint::Spec(bad.to_string());
282 let c = ctx(Some(120.0), 0.0, 0.0);
283 assert_eq!(
284 tp.resolve_relative(&c),
285 Err(TimeError::Unparseable(bad.to_string())),
286 "expected `{bad}` to be unparseable"
287 );
288 }
289 }
290
291 #[test]
292 fn error_display_matches_the_frozen_messages() {
293 let no_bpm = TimeError::NoBpm("8b".to_string());
294 assert_eq!(
295 no_bpm.to_string(),
296 "beat unit in `8b` but the scenario declares no bpm"
297 );
298 let unparseable = TimeError::Unparseable("bogus".to_string());
299 assert_eq!(unparseable.to_string(), "cannot parse time `bogus`");
300 }
301
302 #[test]
303 fn validate_grammar_accepts_every_frozen_example_with_no_bpm_in_scope() {
304 for good in ["2.5s", "8b", "120ms", "8b+120ms", "@8b", "@2.2s-1b"] {
305 let tp = TimePoint::Spec(good.to_string());
306 assert_eq!(
307 tp.validate_grammar(),
308 Ok(()),
309 "`{good}` must pass grammar validation without needing bpm"
310 );
311 }
312 assert_eq!(TimePoint::Seconds(3.0).validate_grammar(), Ok(()));
313 }
314
315 #[test]
316 fn validate_grammar_rejects_the_same_strings_resolution_would() {
317 for bad in ["", "abc", "5xyz", "s5", "5", "+", "-", "5s+"] {
318 let tp = TimePoint::Spec(bad.to_string());
319 assert_eq!(
320 tp.validate_grammar(),
321 Err(TimeError::Unparseable(bad.to_string())),
322 "expected `{bad}` to fail grammar validation"
323 );
324 }
325 }
326
327 #[test]
328 fn validate_grammar_never_reports_no_bpm() {
329 let tp = TimePoint::Spec("@8b".to_string());
330 assert_eq!(tp.validate_grammar(), Ok(()));
331 }
332}