Skip to main content

sofie_core/
moves.rs

1use std::cell::RefCell;
2use std::collections::VecDeque;
3use std::pin::Pin;
4use std::task::{Context, Poll};
5
6use serde::ser::SerializeMap as _;
7use serde::{Serialize, Serializer};
8
9use crate::show::{Fields, Shown};
10use crate::{Cast, Error, FieldError, Result, SqlValue};
11
12const REFUSED_MOVE: &str = "validation.move";
13
14/// `None` for a value that holds no state, such as an absent `Option`.
15pub type Moves<T> = fn(&T) -> Option<&'static Targets>;
16
17/// The states a value may move to, each `true`, as `<field>_to` shows and serializes them.
18#[derive(Debug, PartialEq, Eq)]
19pub struct Targets {
20    pub of: &'static str,
21    pub states: &'static [&'static str],
22}
23
24impl Targets {
25    #[must_use]
26    pub fn contains(&self, name: &str) -> bool {
27        self.states.contains(&name)
28    }
29}
30
31#[must_use]
32pub fn refused(field: impl Into<String>, from: &str, to: &str) -> Error {
33    let error = FieldError::new(
34        field,
35        REFUSED_MOVE,
36        format!("cannot move from {from} to {to}"),
37    )
38    .with("from", from)
39    .with("to", to);
40    Error::Invalid(vec![error])
41}
42
43#[must_use]
44pub fn targets<T: Cast>(value: &T) -> Option<&'static Targets> {
45    T::MOVES.and_then(|moves| moves(value))
46}
47
48pub fn refuse_unlisted<T: Cast>(field: &str, stored: &T, current: &T) -> Result<()> {
49    let Some(targets) = targets(stored) else {
50        return Ok(());
51    };
52    let (mut old, mut new) = (Vec::new(), Vec::new());
53    stored.encode(&mut old);
54    current.encode(&mut new);
55    if new == old {
56        return Ok(());
57    }
58    match new.as_slice() {
59        // Taken first, so a listed move also uses up the record's own journal entry.
60        [SqlValue::Text(target)]
61            if take(targets.of, state_name(&old), target) || targets.contains(target) =>
62        {
63            Ok(())
64        }
65        _ => Err(refused(field, state_name(&old), state_name(&new))),
66    }
67}
68
69fn state_name(columns: &[SqlValue]) -> &str {
70    match columns {
71        [SqlValue::Text(name)] => name,
72        _ => "nothing",
73    }
74}
75
76impl Fields for Targets {
77    fn name(&self, index: usize) -> Option<&str> {
78        self.states.get(index).copied()
79    }
80
81    fn get(&self, name: &str) -> Option<Shown<'_>> {
82        self.contains(name).then_some(Shown::Bool(true))
83    }
84}
85
86impl Serialize for Targets {
87    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
88        let mut map = serializer.serialize_map(Some(self.states.len()))?;
89        for name in self.states {
90            map.serialize_entry(name, &true)?;
91        }
92        map.end()
93    }
94}
95
96/// Outside `recorded` moves land in the thread's own journal, which keeps only the latest.
97const UNSCOPED_LIMIT: usize = 64;
98
99thread_local! {
100    static JOURNAL: RefCell<Journal> = const {
101        RefCell::new(Journal {
102            moves: Vec::new(),
103            scoped: false,
104        })
105    };
106}
107
108struct Journal {
109    moves: Vec<Move>,
110    /// A scope's journal ends with its future, so it keeps every move.
111    scoped: bool,
112}
113
114#[derive(Clone, Copy)]
115struct Move {
116    of: &'static str,
117    from: &'static str,
118    to: &'static str,
119}
120
121pub(crate) fn record(of: &'static str, from: &'static str, to: &'static str) {
122    JOURNAL.with_borrow_mut(|journal| {
123        if !journal.scoped && journal.moves.len() >= UNSCOPED_LIMIT {
124            journal.moves.remove(0);
125        }
126        journal.moves.push(Move { of, from, to });
127    });
128}
129
130/// A value carries no record, so the moves through `Variants::to` that explain a save are taken
131/// out: each explains one record's change, never another's.
132#[must_use]
133pub fn take(of: &str, from: &str, to: &str) -> bool {
134    JOURNAL.with_borrow_mut(|journal| {
135        let Some(mut chain) = chain(&journal.moves, of, from, to) else {
136            return false;
137        };
138        chain.sort_unstable_by(|first, second| second.cmp(first));
139        for index in chain {
140            journal.moves.remove(index);
141        }
142        true
143    })
144}
145
146fn chain(moves: &[Move], of: &str, from: &str, to: &str) -> Option<Vec<usize>> {
147    let mut before: Vec<Option<usize>> = vec![None; moves.len()];
148    let mut seen: Vec<bool> = moves
149        .iter()
150        .map(|step| step.of == of && step.from == from)
151        .collect();
152    let mut queue: VecDeque<usize> = (0..moves.len()).filter(|index| seen[*index]).collect();
153    while let Some(index) = queue.pop_front() {
154        let step = moves[index];
155        if step.to == to {
156            let mut chain = vec![index];
157            while let Some(previous) = before[*chain.last()?] {
158                chain.push(previous);
159            }
160            return Some(chain);
161        }
162        for (next, candidate) in moves.iter().enumerate() {
163            if !seen[next] && candidate.of == of && candidate.from == step.to {
164                seen[next] = true;
165                before[next] = Some(index);
166                queue.push_back(next);
167            }
168        }
169    }
170    None
171}
172
173/// Keeps the moves made while `future` runs with it, across threads and away from other tasks.
174pub fn recorded<F: Future>(future: F) -> Recorded<F> {
175    Recorded {
176        future: Box::pin(future),
177        journal: Journal {
178            moves: Vec::new(),
179            scoped: true,
180        },
181    }
182}
183
184pub struct Recorded<F: Future> {
185    future: Pin<Box<F>>,
186    journal: Journal,
187}
188
189impl<F: Future> Future for Recorded<F> {
190    type Output = F::Output;
191
192    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<F::Output> {
193        let this = &mut *self;
194        let _lent = Lent::new(&mut this.journal);
195        this.future.as_mut().poll(context)
196    }
197}
198
199/// Gives the thread its own journal back in `Drop`, so a panicking future, which a worker
200/// catches, does not leave its journal on the thread.
201struct Lent<'a>(&'a mut Journal);
202
203impl<'a> Lent<'a> {
204    fn new(journal: &'a mut Journal) -> Self {
205        swap_with_thread(journal);
206        Self(journal)
207    }
208}
209
210impl Drop for Lent<'_> {
211    fn drop(&mut self) {
212        swap_with_thread(self.0);
213    }
214}
215
216fn swap_with_thread(journal: &mut Journal) {
217    JOURNAL.with_borrow_mut(|thread| std::mem::swap(thread, journal));
218}
219
220#[cfg(test)]
221mod tests {
222    use std::task::Waker;
223
224    use super::*;
225
226    fn in_scope<T>(work: impl FnOnce() -> T) -> T {
227        let mut scoped = std::pin::pin!(recorded(async { work() }));
228        match scoped
229            .as_mut()
230            .poll(&mut Context::from_waker(Waker::noop()))
231        {
232            Poll::Ready(answer) => answer,
233            Poll::Pending => unreachable!("the work never waits"),
234        }
235    }
236
237    #[test]
238    fn targets_show_and_serialize_each_state_as_true() {
239        let targets = Targets {
240            of: "Light",
241            states: &["RED"],
242        };
243
244        assert!(targets.contains("RED") && !targets.contains("GREEN"));
245        assert_eq!((targets.name(0), targets.name(1)), (Some("RED"), None));
246        assert!(matches!(targets.get("RED"), Some(Shown::Bool(true))));
247        assert!(targets.get("GREEN").is_none());
248        assert_eq!(
249            serde_json::to_value(&targets).unwrap(),
250            serde_json::json!({ "RED": true })
251        );
252    }
253
254    #[test]
255    fn follows_recorded_steps_of_one_type() {
256        in_scope(|| {
257            record("Order", "OPEN", "PAID");
258            record("Order", "PAID", "SHIPPED");
259            record("Light", "SHIPPED", "GONE");
260
261            assert!(!take("Order", "OPEN", "GONE"));
262            assert!(!take("Order", "SHIPPED", "OPEN"));
263            assert!(take("Order", "OPEN", "SHIPPED"));
264        });
265    }
266
267    #[test]
268    fn a_move_of_another_type_explains_nothing() {
269        in_scope(|| {
270            record("Payment", "PENDING", "PAID");
271
272            assert!(!take("Order", "PENDING", "PAID"));
273            assert!(take("Payment", "PENDING", "PAID"));
274        });
275    }
276
277    #[test]
278    fn a_move_explains_one_record_s_save_only() {
279        in_scope(|| {
280            record("Order", "OPEN", "PAID");
281            record("Order", "PAID", "SHIPPED");
282
283            assert!(take("Order", "OPEN", "PAID"), "the first record's own move");
284            assert!(
285                take("Order", "PAID", "SHIPPED"),
286                "the second record's own move"
287            );
288            assert!(
289                !take("Order", "OPEN", "SHIPPED"),
290                "a third record jumping by assignment"
291            );
292        });
293    }
294
295    #[test]
296    fn a_state_is_not_reached_from_itself_without_a_round_trip() {
297        in_scope(|| {
298            record("Order", "OPEN", "PAID");
299
300            assert!(!take("Order", "OPEN", "OPEN"));
301            record("Order", "PAID", "OPEN");
302            assert!(take("Order", "OPEN", "OPEN"));
303        });
304    }
305
306    #[test]
307    fn a_scope_keeps_every_move_however_many() {
308        in_scope(|| {
309            record("Order", "OPEN", "PAID");
310            for _ in 0..UNSCOPED_LIMIT * 2 {
311                record("Light", "RED", "GREEN");
312            }
313
314            assert!(take("Order", "OPEN", "PAID"));
315        });
316    }
317
318    #[test]
319    fn a_recorded_future_keeps_its_moves_to_itself() {
320        let inside = in_scope(|| {
321            record("Order", "OPEN", "PAID");
322            take("Order", "OPEN", "PAID")
323        });
324
325        assert!(inside);
326        assert!(!take("Order", "OPEN", "PAID"));
327    }
328
329    #[test]
330    fn a_panicking_future_gives_the_thread_its_own_journal_back() {
331        let caught = std::panic::catch_unwind(|| {
332            in_scope(|| {
333                record("Order", "OPEN", "PAID");
334                panic!("the handler failed");
335            });
336        });
337
338        assert!(caught.is_err());
339        assert!(!take("Order", "OPEN", "PAID"));
340    }
341}