saccade 0.1.1

Visual-regression CLI for renderers and graphics code, built on NVIDIA FLIP
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, missing_docs)]
use image::{Rgb, RgbImage};
use std::path::Path;
use std::process::Command;

fn image(path: &Path, shade: u8) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    RgbImage::from_pixel(32, 32, Rgb([shade; 3]))
        .save(path)
        .unwrap();
}

fn ingest(format: &str, inputs: &[&Path], out: &Path) {
    let mut command = Command::new(env!("CARGO_BIN_EXE_saccade"));
    command.arg("ingest").arg(format);
    command.args(inputs).arg("--out").arg(out).arg("--json");
    let output = command.output().unwrap();
    assert_eq!(output.status.code(), Some(1), "{format}: {output:?}");
    let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(result["totals"]["fail"], 1, "{format}: {result}");
    assert!(out.join("report/saccade-report.v1.json").is_file());
    assert!(out.join("ingest-mapping.json").is_file());
}

#[test]
fn documented_engine_layouts_pair_synthetic_images() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let blender = root.join("blender");
    image(&blender.join("lighting/ref/lamp.png"), 40);
    image(&blender.join("lighting/lamp.png"), 220);
    ingest("blender", &[&blender], &root.join("blender-out"));

    let bevy_ref = root.join("bevy-reference");
    let bevy_run = root.join("bevy-run");
    image(&bevy_ref.join("screenshot-0.png"), 40);
    image(&bevy_run.join("screenshot-0.png"), 220);
    ingest("bevy", &[&bevy_ref, &bevy_run], &root.join("bevy-out"));

    let unity = root.join("unity/Assets");
    image(
        &unity.join("ReferenceImages/Linear/LinuxEditor/lamp.png"),
        40,
    );
    image(
        &unity.join("ActualImages/Linear/LinuxEditor/Vulkan/lamp.png"),
        220,
    );
    ingest("unity", &[&unity], &root.join("unity-out"));

    let unreal = root.join("unreal");
    image(&unreal.join("approved.png"), 40);
    image(&unreal.join("incoming.png"), 220);
    let results = unreal.join("comparison.json");
    std::fs::write(&results, r#"[{"ReportApprovedFilePath":"approved.png","ReportIncomingFilePath":"incoming.png","ReportComparisonFilePath":"","ScreenshotPath":"Lamp","SourcePlatform":"Linux","SourceRHI":"Vulkan"}]"#).unwrap();
    ingest("unreal", &[&results], &root.join("unreal-out"));
}

#[test]
fn unreal_manifest_rejects_parent_absolute_and_symlink_escape() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("results");
    std::fs::create_dir(&root).unwrap();
    let outside = tmp.path().join("outside.png");
    image(&outside, 40);
    image(&root.join("inside.png"), 220);
    #[cfg(unix)]
    std::os::unix::fs::symlink(&outside, root.join("link.png")).unwrap();
    let paths = [
        "../outside.png".to_string(),
        outside.to_string_lossy().into_owned(),
    ];
    #[cfg(unix)]
    let paths = paths
        .into_iter()
        .chain(["link.png".into()])
        .collect::<Vec<_>>();
    for (index, escape) in paths.iter().enumerate() {
        let manifest = root.join("comparison.json");
        std::fs::write(&manifest, serde_json::json!({"ReportApprovedFilePath":escape,"ReportIncomingFilePath":"inside.png"}).to_string()).unwrap();
        let out = tmp.path().join(format!("out-{index}"));
        let result = Command::new(env!("CARGO_BIN_EXE_saccade"))
            .args(["ingest", "unreal"])
            .arg(&manifest)
            .arg("--out")
            .arg(&out)
            .output()
            .unwrap();
        assert_ne!(result.status.code(), Some(0), "{result:?}");
        assert!(
            String::from_utf8_lossy(&result.stderr).contains("ingest path"),
            "{result:?}"
        );
        assert!(!out.exists());
    }
}

#[cfg(unix)]
#[test]
fn directory_layouts_reject_symlinked_images() {
    let tmp = tempfile::tempdir().unwrap();
    let outside = tmp.path().join("outside.png");
    image(&outside, 40);
    let blender = tmp.path().join("blender");
    std::fs::create_dir_all(blender.join("lighting/ref")).unwrap();
    std::os::unix::fs::symlink(&outside, blender.join("lighting/ref/lamp.png")).unwrap();
    let bevy_ref = tmp.path().join("bevy-ref");
    let bevy_cap = tmp.path().join("bevy-cap");
    std::fs::create_dir(&bevy_ref).unwrap();
    std::fs::create_dir(&bevy_cap).unwrap();
    std::os::unix::fs::symlink(&outside, bevy_ref.join("screenshot-0.png")).unwrap();
    image(&bevy_cap.join("screenshot-0.png"), 220);
    let unity = tmp.path().join("unity/Assets");
    std::fs::create_dir_all(unity.join("ReferenceImages")).unwrap();
    std::fs::create_dir_all(unity.join("ActualImages")).unwrap();
    std::os::unix::fs::symlink(&outside, unity.join("ReferenceImages/lamp.png")).unwrap();
    image(&unity.join("ActualImages/lamp.png"), 220);
    for (format, inputs) in [
        ("blender", vec![blender]),
        ("bevy", vec![bevy_ref, bevy_cap]),
        ("unity", vec![unity]),
    ] {
        let out = tmp.path().join(format!("{format}-out"));
        let mut command = Command::new(env!("CARGO_BIN_EXE_saccade"));
        command
            .arg("ingest")
            .arg(format)
            .args(inputs)
            .arg("--out")
            .arg(&out);
        let result = command.output().unwrap();
        assert_ne!(result.status.code(), Some(0), "{format}: {result:?}");
        assert!(
            String::from_utf8_lossy(&result.stderr).contains("symlink"),
            "{format}: {result:?}"
        );
        assert!(!out.exists());
    }
}