Another project
1use core::fmt;
2use core::num::NonZeroU64;
3use core::str::FromStr;
4use std::path::PathBuf;
5
6use accesskit::NodeId;
7use bone_app::{FrameCount, ScrollDelta};
8use bone_render::ViewportPx;
9use bone_ui::WidgetId;
10use bone_ui::input::{KeyCode, ModifierMask, PointerButton};
11use bone_ui::theme::ThemeMode;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(try_from = "String", into = "String")]
17pub struct NodeRef(NonZeroU64);
18
19impl NodeRef {
20 #[must_use]
21 pub const fn from_widget(id: WidgetId) -> Self {
22 Self(id.raw())
23 }
24
25 #[must_use]
26 pub const fn node_id(self) -> NodeId {
27 NodeId(self.0.get())
28 }
29
30 #[must_use]
31 pub const fn from_node_id(id: NodeId) -> Option<Self> {
32 match NonZeroU64::new(id.0) {
33 Some(raw) => Some(Self(raw)),
34 None => None,
35 }
36 }
37}
38
39impl fmt::Display for NodeRef {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "{:016x}", self.0)
42 }
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Error)]
46#[error("node ref {text:?} is not a non-zero 64-bit hex value")]
47pub struct NodeRefParseError {
48 text: String,
49}
50
51impl FromStr for NodeRef {
52 type Err = NodeRefParseError;
53
54 fn from_str(s: &str) -> Result<Self, Self::Err> {
55 u64::from_str_radix(s, 16)
56 .ok()
57 .and_then(NonZeroU64::new)
58 .map(Self)
59 .ok_or_else(|| NodeRefParseError { text: s.to_owned() })
60 }
61}
62
63impl TryFrom<String> for NodeRef {
64 type Error = NodeRefParseError;
65
66 fn try_from(value: String) -> Result<Self, Self::Error> {
67 value.parse()
68 }
69}
70
71impl From<NodeRef> for String {
72 fn from(value: NodeRef) -> Self {
73 value.to_string()
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(try_from = "String", into = "String")]
79pub struct ArtifactName(String);
80
81impl fmt::Display for ArtifactName {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.write_str(&self.0)
84 }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Error)]
88#[error("artifact name {text:?} must be non-empty ascii alphanumeric with '-' or '_'")]
89pub struct ArtifactNameError {
90 text: String,
91}
92
93impl TryFrom<String> for ArtifactName {
94 type Error = ArtifactNameError;
95
96 fn try_from(value: String) -> Result<Self, Self::Error> {
97 let valid = !value.is_empty()
98 && value
99 .chars()
100 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
101 if valid {
102 Ok(Self(value))
103 } else {
104 Err(ArtifactNameError { text: value })
105 }
106 }
107}
108
109impl From<ArtifactName> for String {
110 fn from(value: ArtifactName) -> Self {
111 value.0
112 }
113}
114
115#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub enum Modifier {
117 Ctrl,
118 Shift,
119 Alt,
120 Meta,
121}
122
123#[must_use]
124pub fn modifier_mask(modifiers: &[Modifier]) -> ModifierMask {
125 modifiers.iter().fold(ModifierMask::NONE, |acc, m| {
126 acc.union(match m {
127 Modifier::Ctrl => ModifierMask::CTRL,
128 Modifier::Shift => ModifierMask::SHIFT,
129 Modifier::Alt => ModifierMask::ALT,
130 Modifier::Meta => ModifierMask::META,
131 })
132 })
133}
134
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
136pub enum PointerTarget {
137 At { x: f64, y: f64 },
138 Node(NodeRef),
139 Label(String),
140}
141
142fn primary_button() -> PointerButton {
143 PointerButton::Primary
144}
145
146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
147pub enum Step {
148 Resize {
149 width: ViewportPx,
150 height: ViewportPx,
151 },
152 Theme(ThemeMode),
153 PointerMove(PointerTarget),
154 Click {
155 target: PointerTarget,
156 #[serde(default = "primary_button")]
157 button: PointerButton,
158 },
159 Drag {
160 from: PointerTarget,
161 to: PointerTarget,
162 #[serde(default = "primary_button")]
163 button: PointerButton,
164 },
165 Wheel(ScrollDelta),
166 Key {
167 code: KeyCode,
168 #[serde(default)]
169 modifiers: Vec<Modifier>,
170 },
171 Text(String),
172 Advance(FrameCount),
173 OpenDocument(PathBuf),
174 Snapshot(ArtifactName),
175 DumpTree(ArtifactName),
176}
177
178#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
179pub struct Scenario {
180 pub steps: Vec<Step>,
181}
182
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub enum ScenarioSource {
185 Path(PathBuf),
186 Stdin,
187}
188
189impl fmt::Display for ScenarioSource {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 match self {
192 Self::Path(path) => write!(f, "{}", path.display()),
193 Self::Stdin => f.write_str("stdin"),
194 }
195 }
196}
197
198#[derive(Debug, Error)]
199pub enum ScenarioError {
200 #[error("read scenario from {from}: {error}")]
201 Read {
202 from: ScenarioSource,
203 error: std::io::Error,
204 },
205 #[error("parse scenario: {0}")]
206 Parse(#[from] ron::error::SpannedError),
207}
208
209impl Scenario {
210 pub fn parse(text: &str) -> Result<Self, ron::error::SpannedError> {
211 ron::de::from_str(text)
212 }
213
214 pub fn load(source: &ScenarioSource) -> Result<Self, ScenarioError> {
215 let text = match source {
216 ScenarioSource::Path(path) => std::fs::read_to_string(path),
217 ScenarioSource::Stdin => std::io::read_to_string(std::io::stdin()),
218 }
219 .map_err(|error| ScenarioError::Read {
220 from: source.clone(),
221 error,
222 })?;
223 Ok(Self::parse(&text)?)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use bone_ui::input::NamedKey;
230
231 use super::*;
232
233 fn parsed(text: &str) -> Scenario {
234 match Scenario::parse(text) {
235 Ok(scenario) => scenario,
236 Err(e) => panic!("parse failed: {e}"),
237 }
238 }
239
240 fn frames(n: u32) -> FrameCount {
241 match FrameCount::try_from(n) {
242 Ok(count) => count,
243 Err(e) => panic!("frame count: {e}"),
244 }
245 }
246
247 #[test]
248 fn full_step_vocabulary_parses() {
249 let scenario = parsed(
250 r#"Scenario(
251 steps: [
252 Resize(width: 1280, height: 800),
253 OpenDocument("parts/bracket.step"),
254 Advance(3),
255 PointerMove(Label("Extrude")),
256 Click(target: At(x: 640.0, y: 400.0)),
257 Click(target: Node("00000000000000ff"), button: Secondary),
258 Drag(from: At(x: 100.0, y: 100.0), to: At(x: 220.0, y: 160.0)),
259 Wheel(Lines(x: 0.0, y: 1.0)),
260 Key(code: Named(Escape)),
261 Key(code: Char('z'), modifiers: [Ctrl, Shift]),
262 Text("12.5"),
263 Snapshot("after-extrude"),
264 DumpTree("final"),
265 ],
266 )"#,
267 );
268 assert_eq!(scenario.steps.len(), 13);
269 assert_eq!(
270 scenario.steps[4],
271 Step::Click {
272 target: PointerTarget::At { x: 640.0, y: 400.0 },
273 button: PointerButton::Primary,
274 },
275 "click button defaults to primary",
276 );
277 assert_eq!(
278 scenario.steps[8],
279 Step::Key {
280 code: KeyCode::Named(NamedKey::Escape),
281 modifiers: vec![],
282 },
283 "key modifiers default to empty",
284 );
285 assert_eq!(scenario.steps[2], Step::Advance(frames(3)));
286 }
287
288 #[test]
289 fn advance_rejects_zero_frames() {
290 assert!(
291 Scenario::parse("Scenario(steps: [Advance(0)])").is_err(),
292 "a zero-frame advance is not representable",
293 );
294 assert!(Scenario::parse("Scenario(steps: [Advance(1)])").is_ok());
295 }
296
297 #[test]
298 fn scenario_round_trips_through_ron() {
299 let scenario = parsed(
300 r#"Scenario(
301 steps: [
302 Click(target: Label("OK")),
303 Wheel(Pixels(x: 0.0, y: -40.0)),
304 Snapshot("dialog"),
305 ],
306 )"#,
307 );
308 let text = match ron::ser::to_string(&scenario) {
309 Ok(t) => t,
310 Err(e) => panic!("serialize failed: {e}"),
311 };
312 assert_eq!(parsed(&text), scenario);
313 }
314
315 #[test]
316 fn node_ref_round_trips_as_hex() {
317 let node = NodeRef::from_widget(WidgetId::ROOT);
318 assert_eq!(node.to_string(), "b0feb0feb0feb0fe");
319 assert_eq!("b0feb0feb0feb0fe".parse(), Ok(node));
320 }
321
322 #[test]
323 fn node_ref_rejects_zero_and_garbage() {
324 assert!(NodeRef::from_str("0").is_err());
325 assert!(NodeRef::from_str("not-hex").is_err());
326 assert!(NodeRef::from_str("").is_err());
327 assert!(NodeRef::from_node_id(NodeId(0)).is_none());
328 }
329
330 #[test]
331 fn artifact_name_rejects_path_traversal() {
332 assert!(ArtifactName::try_from("../escape".to_owned()).is_err());
333 assert!(ArtifactName::try_from(String::new()).is_err());
334 assert!(ArtifactName::try_from("a/b".to_owned()).is_err());
335 assert!(ArtifactName::try_from("cold_shell-01".to_owned()).is_ok());
336 }
337
338 #[test]
339 fn modifier_mask_folds_all_flags() {
340 let mask = modifier_mask(&[Modifier::Ctrl, Modifier::Shift]);
341 assert!(mask.contains(ModifierMask::CTRL));
342 assert!(mask.contains(ModifierMask::SHIFT));
343 assert!(!mask.contains(ModifierMask::ALT));
344 assert_eq!(modifier_mask(&[]), ModifierMask::NONE);
345 }
346
347 #[test]
348 fn load_reads_a_scenario_file() {
349 let dir = match tempfile::tempdir() {
350 Ok(d) => d,
351 Err(e) => panic!("tempdir: {e}"),
352 };
353 let path = dir.path().join("smoke.ron");
354 if let Err(e) = std::fs::write(&path, "Scenario(steps: [Advance(1)])") {
355 panic!("write: {e}");
356 }
357 let scenario = match Scenario::load(&ScenarioSource::Path(path)) {
358 Ok(s) => s,
359 Err(e) => panic!("load: {e}"),
360 };
361 assert_eq!(scenario.steps, vec![Step::Advance(FrameCount::ONE)]);
362 }
363
364 #[test]
365 fn load_reports_the_missing_path() {
366 let source = ScenarioSource::Path(PathBuf::from("/nonexistent/limpet.ron"));
367 match Scenario::load(&source) {
368 Err(ScenarioError::Read { from, .. }) => assert_eq!(from, source),
369 other => panic!("expected read error, got {other:?}"),
370 }
371 }
372}