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;

#[cfg(windows)]
const CAPTURE: &str = r#"copy /Y "frame.png" "%SACCADE_CAPTURE_DIR%\frame.png""#;
#[cfg(not(windows))]
const CAPTURE: &str = r#"cp "frame.png" "$SACCADE_CAPTURE_DIR/frame.png""#;
#[cfg(windows)]
const DIRTY_CAPTURE: &str =
    r#"type nul > dirty-in-clone & copy /Y "frame.png" "%SACCADE_CAPTURE_DIR%\frame.png""#;
#[cfg(not(windows))]
const DIRTY_CAPTURE: &str =
    r#"touch dirty-in-clone; cp "frame.png" "$SACCADE_CAPTURE_DIR/frame.png""#;
#[cfg(windows)]
const FAILED_CAPTURE: &str = "exit /B 7";
#[cfg(not(windows))]
const FAILED_CAPTURE: &str = "exit 7";

fn git(repo: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(args)
        .output()
        .unwrap();
    assert!(output.status.success(), "{output:?}");
    String::from_utf8(output.stdout).unwrap().trim().into()
}

#[test]
fn finds_first_bad_and_restores_original_head() {
    let tmp = tempfile::tempdir().unwrap();
    let repo = tmp.path().join("project with spaces");
    std::fs::create_dir(&repo).unwrap();
    git(&repo, &["init", "-q"]);
    git(&repo, &["config", "user.name", "Test"]);
    git(&repo, &["config", "user.email", "test@example.invalid"]);
    let frame = repo.join("frame.png");
    let write = |shade| {
        RgbImage::from_pixel(32, 32, Rgb([shade; 3]))
            .save(&frame)
            .unwrap()
    };
    write(60);
    git(&repo, &["add", "frame.png"]);
    git(&repo, &["commit", "-qm", "good"]);
    let good = git(&repo, &["rev-parse", "HEAD"]);
    let baseline = tmp.path().join("baseline with spaces");
    std::fs::create_dir(&baseline).unwrap();
    std::fs::copy(&frame, baseline.join("frame.png")).unwrap();
    std::fs::write(repo.join("note.txt"), "middle").unwrap();
    git(&repo, &["add", "note.txt"]);
    git(&repo, &["commit", "-qm", "middle"]);
    write(230);
    git(&repo, &["add", "frame.png"]);
    git(&repo, &["commit", "-qm", "bad"]);
    let first_bad = git(&repo, &["rev-parse", "HEAD"]);
    std::fs::write(repo.join("note.txt"), "still bad").unwrap();
    git(&repo, &["add", "note.txt"]);
    git(&repo, &["commit", "-qm", "still bad"]);
    let bad = git(&repo, &["rev-parse", "HEAD"]);
    let branch = git(&repo, &["branch", "--show-current"]);
    let out = tmp.path().join("evidence with spaces");
    let result = Command::new(env!("CARGO_BIN_EXE_saccade"))
        .current_dir(&repo)
        .arg("bisect")
        .args(["--good", &good, "--bad", &bad, "--capture", CAPTURE])
        .arg("--baseline")
        .arg(&baseline)
        .arg("--out")
        .arg(&out)
        .arg("--json")
        .output()
        .unwrap();
    let evidence = std::fs::read_to_string(out.join("saccade-git-bisect.v1.json")).unwrap();
    assert_eq!(
        result.status.code(),
        Some(1),
        "{result:?}\n{evidence}\n{}",
        std::fs::read_to_string(out.join("git-bisect.log")).unwrap()
    );
    let result_json: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
    assert_eq!(result_json["data"]["first_bad"], first_bad);
    let evidence: serde_json::Value = serde_json::from_str(&evidence).unwrap();
    assert_eq!(evidence["git_bisect_exit"], 0);
    let steps = evidence["steps"].as_array().unwrap();
    for verdict in ["good", "bad"] {
        assert!(steps.iter().any(|step| step["verdict"] == verdict));
    }
    assert!(steps.iter().all(|step| step["capture_exit"] == 0));
    assert_eq!(git(&repo, &["rev-parse", "HEAD"]), bad);
    assert_eq!(git(&repo, &["branch", "--show-current"]), branch);
    assert_eq!(git(&repo, &["status", "--porcelain"]), "");
    assert!(out.join("saccade-git-bisect.v1.json").is_file());
    std::fs::write(repo.join("scratch"), "dirty").unwrap();
    let refused = Command::new(env!("CARGO_BIN_EXE_saccade"))
        .current_dir(&repo)
        .arg("bisect")
        .args(["--good", &good, "--bad", &bad, "--capture", CAPTURE])
        .arg("--baseline")
        .arg(&baseline)
        .arg("--out")
        .arg(tmp.path().join("unused"))
        .output()
        .unwrap();
    assert_eq!(refused.status.code(), Some(2));
    assert_eq!(git(&repo, &["rev-parse", "HEAD"]), bad);
    assert!(!tmp.path().join("unused").exists());
    std::fs::remove_file(repo.join("scratch")).unwrap();
    let dirty_capture = Command::new(env!("CARGO_BIN_EXE_saccade"))
        .current_dir(&repo)
        .arg("bisect")
        .args(["--good", &good, "--bad", &bad, "--capture", DIRTY_CAPTURE])
        .arg("--baseline")
        .arg(&baseline)
        .arg("--out")
        .arg(tmp.path().join("dirty-evidence"))
        .arg("--json")
        .output()
        .unwrap();
    assert_eq!(dirty_capture.status.code(), Some(2), "{dirty_capture:?}");
    let dirty_evidence: serde_json::Value = serde_json::from_slice(
        &std::fs::read(tmp.path().join("dirty-evidence/saccade-git-bisect.v1.json")).unwrap(),
    )
    .unwrap();
    assert_eq!(dirty_evidence["steps"][0]["verdict"], "aborted");
    assert_eq!(dirty_evidence["steps"][0]["capture_exit"], 0);
    assert_ne!(dirty_evidence["git_bisect_exit"], 0);
    let failed_out = tmp.path().join("failed-evidence");
    let failed_capture = Command::new(env!("CARGO_BIN_EXE_saccade"))
        .current_dir(&repo)
        .arg("bisect")
        .args(["--good", &good, "--bad", &bad, "--capture", FAILED_CAPTURE])
        .arg("--baseline")
        .arg(&baseline)
        .arg("--out")
        .arg(&failed_out)
        .arg("--json")
        .output()
        .unwrap();
    assert_eq!(failed_capture.status.code(), Some(2), "{failed_capture:?}");
    let failed_evidence: serde_json::Value = serde_json::from_slice(
        &std::fs::read(failed_out.join("saccade-git-bisect.v1.json")).unwrap(),
    )
    .unwrap();
    assert!(failed_evidence["first_bad"].is_null());
    assert_ne!(failed_evidence["git_bisect_exit"], 0);
    let steps = failed_evidence["steps"].as_array().unwrap();
    assert!(!steps.is_empty());
    assert!(steps.iter().all(|step| {
        step["verdict"] == "skip"
            && step["reason"] == "capture command failed"
            && step["capture_exit"] == 7
    }));
    assert_eq!(git(&repo, &["rev-parse", "HEAD"]), bad);
    assert_eq!(git(&repo, &["status", "--porcelain"]), "");
}