Another project
1use std::path::PathBuf;
2use std::process::ExitCode;
3
4use bone_jig::{RunResult, RunStatus, Scenario, ScenarioSource, Step, StepOutcome, run_scenario};
5use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png_rgba};
6use thiserror::Error;
7
8const USAGE: &str = "usage:
9 bone-jig run <scenario.ron|-> --out <dir> [--document <part>]
10 bone-jig diff <a.png> <b.png> [--out <diff.png>] [--threshold <0-255>]";
11
12const EXIT_FAILED: u8 = 1;
13const EXIT_USAGE: u8 = 2;
14const EXIT_ERROR: u8 = 3;
15
16#[derive(Debug, PartialEq, Eq, Error)]
17enum CliError {
18 #[error("missing command")]
19 MissingCommand,
20 #[error("unknown command: {0}")]
21 UnknownCommand(String),
22 #[error("missing argument: {0}")]
23 MissingArg(&'static str),
24 #[error("unexpected argument: {0}")]
25 UnexpectedArg(String),
26 #[error("threshold {0:?} is not an integer in 0-255")]
27 BadThreshold(String),
28}
29
30#[derive(Debug, PartialEq)]
31enum Command {
32 Run {
33 scenario: ScenarioSource,
34 out: PathBuf,
35 document: Option<PathBuf>,
36 },
37 Diff {
38 left: PathBuf,
39 right: PathBuf,
40 out: Option<PathBuf>,
41 threshold: PixelDiffThreshold,
42 },
43}
44
45fn parse(args: &[String]) -> Result<Command, CliError> {
46 match args {
47 [] => Err(CliError::MissingCommand),
48 [command, rest @ ..] if command == "run" => parse_run(rest, RunArgs::default()),
49 [command, rest @ ..] if command == "diff" => parse_diff(rest, DiffArgs::default()),
50 [command, ..] => Err(CliError::UnknownCommand(command.clone())),
51 }
52}
53
54#[derive(Default)]
55struct RunArgs {
56 scenario: Option<ScenarioSource>,
57 out: Option<PathBuf>,
58 document: Option<PathBuf>,
59}
60
61fn scenario_source(arg: &str) -> ScenarioSource {
62 if arg == "-" {
63 ScenarioSource::Stdin
64 } else {
65 ScenarioSource::Path(PathBuf::from(arg))
66 }
67}
68
69fn parse_run(args: &[String], acc: RunArgs) -> Result<Command, CliError> {
70 match args {
71 [] => Ok(Command::Run {
72 scenario: acc.scenario.ok_or(CliError::MissingArg("<scenario.ron>"))?,
73 out: acc.out.ok_or(CliError::MissingArg("--out <dir>"))?,
74 document: acc.document,
75 }),
76 [flag, value, rest @ ..] if flag == "--out" => parse_run(
77 rest,
78 RunArgs {
79 out: Some(PathBuf::from(value)),
80 ..acc
81 },
82 ),
83 [flag, value, rest @ ..] if flag == "--document" => parse_run(
84 rest,
85 RunArgs {
86 document: Some(PathBuf::from(value)),
87 ..acc
88 },
89 ),
90 [flag] if flag == "--out" => Err(CliError::MissingArg("--out <dir>")),
91 [flag] if flag == "--document" => Err(CliError::MissingArg("--document <part>")),
92 [positional, rest @ ..] if acc.scenario.is_none() && !positional.starts_with("--") => {
93 parse_run(
94 rest,
95 RunArgs {
96 scenario: Some(scenario_source(positional)),
97 ..acc
98 },
99 )
100 }
101 [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())),
102 }
103}
104
105#[derive(Default)]
106struct DiffArgs {
107 images: Vec<PathBuf>,
108 out: Option<PathBuf>,
109 threshold: Option<PixelDiffThreshold>,
110}
111
112fn parse_threshold(value: &str) -> Result<PixelDiffThreshold, CliError> {
113 value
114 .parse::<u8>()
115 .map(|n| PixelDiffThreshold::new(f64::from(n) / 255.0))
116 .map_err(|_| CliError::BadThreshold(value.to_owned()))
117}
118
119fn parse_diff(args: &[String], acc: DiffArgs) -> Result<Command, CliError> {
120 match args {
121 [] => match <[PathBuf; 2]>::try_from(acc.images) {
122 Ok([left, right]) => Ok(Command::Diff {
123 left,
124 right,
125 out: acc.out,
126 threshold: acc.threshold.unwrap_or(PixelDiffThreshold::EXACT),
127 }),
128 Err(_) => Err(CliError::MissingArg("<a.png> <b.png>")),
129 },
130 [flag, value, rest @ ..] if flag == "--out" => parse_diff(
131 rest,
132 DiffArgs {
133 out: Some(PathBuf::from(value)),
134 ..acc
135 },
136 ),
137 [flag, value, rest @ ..] if flag == "--threshold" => parse_diff(
138 rest,
139 DiffArgs {
140 threshold: Some(parse_threshold(value)?),
141 ..acc
142 },
143 ),
144 [flag] if flag == "--out" => Err(CliError::MissingArg("--out <diff.png>")),
145 [flag] if flag == "--threshold" => Err(CliError::MissingArg("--threshold <0-255>")),
146 [positional, rest @ ..] if acc.images.len() < 2 && !positional.starts_with("--") => {
147 let mut images = acc.images;
148 images.push(PathBuf::from(positional));
149 parse_diff(rest, DiffArgs { images, ..acc })
150 }
151 [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())),
152 }
153}
154
155fn main() -> ExitCode {
156 let args: Vec<String> = std::env::args().skip(1).collect();
157 match parse(&args) {
158 Ok(Command::Run {
159 scenario,
160 out,
161 document,
162 }) => execute_run(&scenario, &out, document),
163 Ok(Command::Diff {
164 left,
165 right,
166 out,
167 threshold,
168 }) => execute_diff(&left, &right, out.as_deref(), threshold),
169 Err(e) => {
170 eprintln!("{e}");
171 eprintln!("{USAGE}");
172 ExitCode::from(EXIT_USAGE)
173 }
174 }
175}
176
177fn with_document(scenario: Scenario, document: Option<PathBuf>) -> Scenario {
178 match document {
179 None => scenario,
180 Some(path) => Scenario {
181 steps: std::iter::once(Step::OpenDocument(path))
182 .chain(scenario.steps)
183 .collect(),
184 },
185 }
186}
187
188fn report_failure(result: &RunResult) {
189 result
190 .steps
191 .iter()
192 .enumerate()
193 .filter_map(|(index, record)| match &record.outcome {
194 StepOutcome::Failed(message) => Some((index, message)),
195 StepOutcome::Ok => None,
196 })
197 .for_each(|(index, message)| eprintln!("step {index} failed: {message}"));
198}
199
200fn execute_run(
201 source: &ScenarioSource,
202 out: &std::path::Path,
203 document: Option<PathBuf>,
204) -> ExitCode {
205 let scenario = match Scenario::load(source) {
206 Ok(s) => with_document(s, document),
207 Err(e) => {
208 eprintln!("{e}");
209 return ExitCode::from(EXIT_ERROR);
210 }
211 };
212 match run_scenario(&scenario, out, &mut std::io::stdout().lock()) {
213 Ok(result) if result.status == RunStatus::Passed => ExitCode::SUCCESS,
214 Ok(result) => {
215 report_failure(&result);
216 ExitCode::from(EXIT_FAILED)
217 }
218 Err(e) => {
219 eprintln!("{e}");
220 ExitCode::from(EXIT_ERROR)
221 }
222 }
223}
224
225fn load_png(path: &std::path::Path) -> Result<(bone_render::ViewportExtent, Vec<u8>), String> {
226 let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
227 decode_png(&bytes).map_err(|e| format!("decode {}: {e}", path.display()))
228}
229
230fn diff_image(left: &[u8], right: &[u8], limit: u8) -> Vec<u8> {
231 left.chunks_exact(4)
232 .zip(right.chunks_exact(4))
233 .flat_map(|(a, b)| {
234 let over = a
235 .iter()
236 .zip(b.iter())
237 .map(|(x, y)| x.abs_diff(*y))
238 .max()
239 .unwrap_or(0)
240 > limit;
241 if over {
242 [255, 0, 0, 255]
243 } else {
244 [0, 0, 0, 255]
245 }
246 })
247 .collect()
248}
249
250fn execute_diff(
251 left: &std::path::Path,
252 right: &std::path::Path,
253 out: Option<&std::path::Path>,
254 threshold: PixelDiffThreshold,
255) -> ExitCode {
256 let ((extent_left, rgba_left), (extent_right, rgba_right)) =
257 match (load_png(left), load_png(right)) {
258 (Ok(l), Ok(r)) => (l, r),
259 (Err(e), _) | (_, Err(e)) => {
260 eprintln!("{e}");
261 return ExitCode::from(EXIT_ERROR);
262 }
263 };
264 if extent_left != extent_right {
265 eprintln!("size mismatch: {extent_left} vs {extent_right}");
266 return ExitCode::from(EXIT_FAILED);
267 }
268 let report = match PixelDiff::compare_bytes(extent_left, &rgba_left, &rgba_right, threshold) {
269 Ok(r) => r,
270 Err(e) => {
271 eprintln!("{e}");
272 return ExitCode::from(EXIT_ERROR);
273 }
274 };
275 println!(
276 "pixels over threshold: {} of {}",
277 report.over_threshold(),
278 extent_left.pixel_count(),
279 );
280 if let Some(worst) = report.worst() {
281 println!(
282 "worst: ({},{}) delta={}",
283 worst.x(),
284 worst.y(),
285 worst.max_delta(),
286 );
287 }
288 if let Some(path) = out {
289 let rgba = diff_image(&rgba_left, &rgba_right, threshold.as_u8());
290 let written = encode_png_rgba(extent_left, &rgba)
291 .map_err(|e| e.to_string())
292 .and_then(|png| {
293 std::fs::write(path, png).map_err(|e| format!("write {}: {e}", path.display()))
294 });
295 if let Err(e) = written {
296 eprintln!("{e}");
297 return ExitCode::from(EXIT_ERROR);
298 }
299 }
300 if report.is_clean() {
301 ExitCode::SUCCESS
302 } else {
303 ExitCode::from(EXIT_FAILED)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn strings(parts: &[&str]) -> Vec<String> {
312 parts.iter().map(|s| (*s).to_owned()).collect()
313 }
314
315 #[test]
316 fn run_parses_scenario_out_and_document() {
317 let cmd = parse(&strings(&[
318 "run",
319 "shell.ron",
320 "--out",
321 "artifacts",
322 "--document",
323 "clamp.step",
324 ]));
325 assert_eq!(
326 cmd,
327 Ok(Command::Run {
328 scenario: ScenarioSource::Path(PathBuf::from("shell.ron")),
329 out: PathBuf::from("artifacts"),
330 document: Some(PathBuf::from("clamp.step")),
331 }),
332 );
333 }
334
335 #[test]
336 fn run_dash_reads_stdin() {
337 let cmd = parse(&strings(&["run", "-", "--out", "artifacts"]));
338 assert_eq!(
339 cmd,
340 Ok(Command::Run {
341 scenario: ScenarioSource::Stdin,
342 out: PathBuf::from("artifacts"),
343 document: None,
344 }),
345 );
346 }
347
348 #[test]
349 fn run_requires_out() {
350 assert_eq!(
351 parse(&strings(&["run", "shell.ron"])),
352 Err(CliError::MissingArg("--out <dir>")),
353 );
354 }
355
356 #[test]
357 fn diff_parses_threshold_and_out() {
358 let cmd = parse(&strings(&[
359 "diff",
360 "a.png",
361 "b.png",
362 "--threshold",
363 "8",
364 "--out",
365 "d.png",
366 ]));
367 assert_eq!(
368 cmd,
369 Ok(Command::Diff {
370 left: PathBuf::from("a.png"),
371 right: PathBuf::from("b.png"),
372 out: Some(PathBuf::from("d.png")),
373 threshold: PixelDiffThreshold::new(8.0 / 255.0),
374 }),
375 );
376 }
377
378 #[test]
379 fn diff_defaults_to_exact_threshold() {
380 let cmd = parse(&strings(&["diff", "a.png", "b.png"]));
381 assert_eq!(
382 cmd,
383 Ok(Command::Diff {
384 left: PathBuf::from("a.png"),
385 right: PathBuf::from("b.png"),
386 out: None,
387 threshold: PixelDiffThreshold::EXACT,
388 }),
389 );
390 }
391
392 #[test]
393 fn diff_rejects_bad_threshold() {
394 assert_eq!(
395 parse(&strings(&["diff", "a.png", "b.png", "--threshold", "256"])),
396 Err(CliError::BadThreshold("256".to_owned())),
397 );
398 assert_eq!(
399 parse(&strings(&["diff", "a.png"])),
400 Err(CliError::MissingArg("<a.png> <b.png>")),
401 );
402 }
403
404 #[test]
405 fn unknown_command_is_rejected() {
406 assert_eq!(
407 parse(&strings(&["serve"])),
408 Err(CliError::UnknownCommand("serve".to_owned())),
409 );
410 assert_eq!(parse(&[]), Err(CliError::MissingCommand));
411 }
412
413 #[test]
414 fn with_document_prepends_an_open_step() {
415 let scenario = Scenario {
416 steps: vec![Step::Advance(bone_app::FrameCount::ONE)],
417 };
418 let amended = with_document(scenario, Some(PathBuf::from("clamp.step")));
419 assert_eq!(
420 amended.steps[0],
421 Step::OpenDocument(PathBuf::from("clamp.step")),
422 );
423 assert_eq!(amended.steps.len(), 2);
424 }
425
426 #[test]
427 fn diff_image_marks_only_over_threshold_pixels() {
428 let left = [0_u8, 0, 0, 255, 100, 0, 0, 255];
429 let right = [0_u8, 0, 0, 255, 0, 0, 0, 255];
430 let image = diff_image(&left, &right, 8);
431 assert_eq!(image, vec![0, 0, 0, 255, 255, 0, 0, 255]);
432 }
433}