Skip to main content

rustmotion_core/schema/
time.rs

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/// A point in time: a bare JSON number of seconds, or a small string
30/// expression anchored to the beat grid.
31///
32/// # Grammar (for the [`Spec`](TimePoint::Spec) string form)
33///
34/// An optional leading `@` (forces the value onto the scenario's absolute
35/// timeline — see the module doc), followed by one or more terms joined by
36/// `+` or `-`. Each term is `<number><unit>`, where `unit` is one of:
37///
38/// - `s` — seconds, taken as-is.
39/// - `ms` — milliseconds, divided by 1000.
40/// - `b` — beats: `beat_offset + n * 60 / bpm` (errors with
41///   [`TimeError::NoBpm`] when the scenario declares no `bpm`). A term's
42///   sign multiplies this *whole* grid position, `beat_offset` included —
43///   so `"@2.2s-1b"` is "2.2s before wherever beat 1 lands," not "1 raw
44///   beat length before 2.2s."
45///
46/// Valid examples: `"2.5s"`, `"8b"`, `"120ms"`, `"8b+120ms"`, `"@8b"`,
47/// `"@2.2s-1b"`.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
49#[serde(untagged)]
50pub enum TimePoint {
51    /// A bare JSON number: seconds, always relative to whatever context
52    /// resolves it (see [`TimePoint::resolve_relative`]).
53    Seconds(f64),
54    /// The string expression form — see the grammar above.
55    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}