1use std::path::Path;
2
3use anyhow::{anyhow, bail, Context as _, Result};
4
5use loam_console::CommandLine;
6use loam_runtime::Stores;
7
8use crate::args::Args;
9use crate::session::SessionConsole;
10
11const SETTLE_FRAMES: u64 = 60;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct ScriptStep {
16 pub frame: u64,
17 pub command: CommandLine,
18}
19
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct Script {
23 steps: Vec<ScriptStep>,
24}
25
26impl Script {
27 pub fn parse(source: &str) -> Result<Self> {
29 let mut steps: Vec<ScriptStep> = Vec::new();
30 for (index, raw) in source.lines().enumerate() {
31 let number = index + 1;
32 let line = raw.trim();
33 if line.is_empty() || line.starts_with('#') {
34 continue;
35 }
36 let (frame_token, rest) = line
37 .split_once(char::is_whitespace)
38 .ok_or_else(|| anyhow!("line {number}: `{line}` names a frame and no command"))?;
39 let frame: u64 = frame_token
40 .parse()
41 .map_err(|e| anyhow!("line {number}: `{frame_token}` is not a frame index: {e}"))?;
42 let command = rest.trim();
43 if command.is_empty() {
44 bail!("line {number}: `{line}` names a frame and no command");
45 }
46 if let Some(previous) = steps.last() {
47 if frame < previous.frame {
48 bail!(
49 "line {number}: frame {frame} precedes frame {}",
50 previous.frame
51 );
52 }
53 }
54 steps.push(ScriptStep {
55 frame,
56 command: CommandLine::parse(command)
57 .ok_or_else(|| anyhow!("line {number}: no command tokens"))?,
58 });
59 }
60 Ok(Self { steps })
61 }
62
63 pub fn load(path: &Path) -> Result<Self> {
64 let source = std::fs::read_to_string(path)
65 .with_context(|| format!("reading script {}", path.display()))?;
66 Self::parse(&source).with_context(|| format!("parsing script {}", path.display()))
67 }
68
69 pub fn steps(&self) -> &[ScriptStep] {
70 &self.steps
71 }
72
73 pub fn last_frame(&self) -> u64 {
74 self.steps.last().map_or(0, |step| step.frame)
75 }
76}
77
78#[derive(Copy, Clone, Debug, PartialEq, Eq)]
79pub enum ScriptStatus {
80 Running,
81 Finished,
82}
83
84#[derive(Debug)]
85pub struct ScriptDriver {
86 steps: std::iter::Peekable<std::vec::IntoIter<ScriptStep>>,
87 frame: u64,
88 exit_frame: u64,
89}
90
91impl ScriptDriver {
92 pub fn new(script: Script) -> Self {
93 let exit_frame = script.last_frame().saturating_add(SETTLE_FRAMES);
94 Self {
95 steps: script.steps.into_iter().peekable(),
96 frame: 0,
97 exit_frame,
98 }
99 }
100
101 pub fn frame(&self) -> u64 {
102 self.frame
103 }
104
105 pub fn advance_with(&mut self, mut submit: impl FnMut(CommandLine)) -> ScriptStatus {
106 while self
107 .steps
108 .peek()
109 .is_some_and(|step| step.frame <= self.frame)
110 {
111 if let Some(step) = self.steps.next() {
112 submit(step.command);
113 }
114 }
115 let status = if self.frame >= self.exit_frame {
116 ScriptStatus::Finished
117 } else {
118 ScriptStatus::Running
119 };
120 self.frame = self.frame.saturating_add(1);
121 status
122 }
123
124 pub fn advance_console<A: Stores>(&mut self, console: &mut SessionConsole<A>) -> ScriptStatus {
125 self.advance_with(|command| {
126 console.execute(&loam_console::render_line(
127 &command.name,
128 &command.arg_refs(),
129 ));
130 })
131 }
132}
133
134pub fn driver_from_args(args: &Args) -> Result<Option<ScriptDriver>> {
135 if args.has_bare_flag("script") {
136 bail!("--script needs its path attached: --script=path/to/file.script");
137 }
138 match args.get("script") {
139 Some(path) => Ok(Some(ScriptDriver::new(Script::load(Path::new(path))?))),
140 None => Ok(None),
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use loam_console::CommandQueue;
148
149 #[test]
150 fn invalid_frame_or_empty_command_is_rejected() {
151 for source in ["10", " 10 ", "-1 reset", "1.5 reset", "abc reset"] {
152 assert!(Script::parse(source).is_err(), "{source}");
153 }
154 }
155
156 #[test]
157 fn descending_frames_report_the_line() {
158 let error = Script::parse("0 reset\n10 spin\n5 hud").unwrap_err();
159 assert!(error.to_string().contains("line 3"));
160 }
161
162 #[test]
163 fn script_requires_an_attached_path() {
164 let args = Args::from_argv(["--script", "some.script"]);
165 assert!(driver_from_args(&args)
166 .unwrap_err()
167 .to_string()
168 .contains("--script="));
169 }
170
171 #[test]
172 fn commands_fire_once_in_frame_and_file_order() {
173 let mut driver = ScriptDriver::new(
174 Script::parse("0 mark boot\n2 mark second-a\n2 mark second-b\n5 mark last").unwrap(),
175 );
176 let mut queue = CommandQueue::new();
177 let mut fired = Vec::new();
178 for frame in 0..8 {
179 driver.advance_with(|command| queue.submit(command));
180 fired.extend(
181 queue
182 .drain()
183 .map(|command| (frame, command.args[0].clone())),
184 );
185 }
186 assert_eq!(
187 fired,
188 [
189 (0, "boot".to_string()),
190 (2, "second-a".to_string()),
191 (2, "second-b".to_string()),
192 (5, "last".to_string()),
193 ]
194 );
195 }
196
197 #[test]
198 fn finish_waits_for_capture_margin() {
199 for source in ["", "5 mark end"] {
200 let script = Script::parse(source).unwrap();
201 let finish = script.last_frame() + SETTLE_FRAMES;
202 let mut driver = ScriptDriver::new(script);
203 for _ in 0..finish {
204 assert_eq!(driver.advance_with(|_| {}), ScriptStatus::Running);
205 }
206 assert_eq!(driver.advance_with(|_| {}), ScriptStatus::Finished);
207 }
208 }
209}
210
211#[cfg(test)]
212mod session_tests {
213 use std::cell::RefCell;
214 use std::rc::Rc;
215
216 use loam_runtime::{Bindings, HostConfig};
217
218 use super::*;
219 use crate::session::SessionApp;
220
221 loam_runtime::stores! {
222 #[derive(Default)]
223 pub struct Marked {
224 hits: Value<u32>,
225 }
226 }
227
228 fn host_marking(seen: &Rc<RefCell<Vec<Vec<String>>>>) -> SessionApp<Marked> {
229 let recorded = seen.clone();
230 SessionApp::with_args(HostConfig::new("script", Bindings::new()), Args::default()).command(
231 "mark",
232 "record a marker",
233 move |args, _submit, _out| {
234 recorded
235 .borrow_mut()
236 .push(args.iter().map(|arg| (*arg).to_string()).collect());
237 Ok(())
238 },
239 )
240 }
241
242 #[test]
243 fn a_scripted_line_reaches_its_verb_at_its_own_frame() {
244 let seen = Rc::new(RefCell::new(Vec::new()));
245 let mut app = host_marking(&seen);
246 let mut driver =
247 ScriptDriver::new(Script::parse("0 mark boot\n2 mark second").expect("script"));
248
249 let mut dispatched = Vec::new();
250 for _ in 0..3 {
251 driver.advance_console(app.console_mut());
252 app.console_mut().dispatch_pending();
253 dispatched.push(seen.borrow().len());
254 }
255
256 assert_eq!(dispatched, [1, 1, 2]);
257 assert_eq!(*seen.borrow(), [vec!["boot"], vec!["second"]]);
258 }
259
260 #[test]
261 fn a_quoted_scripted_argument_stays_one_argument() {
262 let seen = Rc::new(RefCell::new(Vec::new()));
263 let mut app = host_marking(&seen);
264 let mut driver =
265 ScriptDriver::new(Script::parse("0 mark \"a b\" #ff8800").expect("script"));
266
267 driver.advance_console(app.console_mut());
268 app.console_mut().dispatch_pending();
269
270 assert_eq!(*seen.borrow(), [vec!["a b", "#ff8800"]]);
271 }
272}