feat(primitives): 3 Rust cubes — mock-render, visual-diff, tokens-sync

This commit is contained in:
Parfii-bot 2026-04-21 21:07:45 +08:00
parent c94646dd3c
commit ebf841c7d9
13 changed files with 2050 additions and 0 deletions

1089
_primitives/_rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
[package]
name = "mock-render"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "mock-render"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,48 @@
//! SHA-256 helpers. Used for WYSIWYD invariant checks
//! (source file must not mutate between lock and verify).
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
pub fn hash_file(path: &Path) -> Result<String, String> {
let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn hashes_empty_file() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"").unwrap();
let h = hash_file(f.path()).unwrap();
// sha256("") — well-known constant
assert_eq!(
h,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hashes_hello_world() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"hello world").unwrap();
let h = hash_file(f.path()).unwrap();
assert_eq!(
h,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn returns_err_on_missing_file() {
let result = hash_file(Path::new("/nonexistent/path/xyz"));
assert!(result.is_err());
}
}

View file

@ -0,0 +1,226 @@
//! mock-render — enforces the WYSIWYD invariant (What You See Is What's Deployed)
//! for block-based site-builder. Every section = one source file; screenshot is
//! a render of that file; lock freezes the hash; verify fails if source mutated.
//!
//! USAGE
//! mock-render screenshot <url> --out <png> [--viewport WxH]
//! mock-render lock --project <dir> --section <src> [--screenshot <png>]
//! mock-render verify --project <dir> --section <src>
//! mock-render status --project <dir>
mod hash;
mod render;
mod state;
use state::{Section, SiteState};
use std::env;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("screenshot") => cmd_screenshot(&args[1..]),
Some("lock") => cmd_lock(&args[1..]),
Some("verify") => cmd_verify(&args[1..]),
Some("status") => cmd_status(&args[1..]),
Some("--help") | Some("-h") | None => {
print_help();
ExitCode::SUCCESS
}
Some(cmd) => {
eprintln!("mock-render: unknown command '{cmd}'. Run with --help.");
ExitCode::from(1)
}
}
}
fn print_help() {
println!(
"mock-render — WYSIWYD invariant enforcer for site-builder
USAGE
mock-render screenshot <url> --out <png> [--viewport WxH]
mock-render lock --project <dir> --section <src> [--screenshot <png>]
mock-render verify --project <dir> --section <src>
mock-render status --project <dir>
EXIT
0 ok
1 usage / missing args
2 WYSIWYD invariant violated (file drift / hash mismatch)"
);
}
fn cmd_screenshot(args: &[String]) -> ExitCode {
let Some(url) = args.first().cloned() else {
eprintln!("screenshot: <url> required");
return ExitCode::from(1);
};
let out = match flag(args, "--out") {
Some(p) => PathBuf::from(p),
None => {
eprintln!("screenshot: --out <png> required");
return ExitCode::from(1);
}
};
let viewport = flag(args, "--viewport").and_then(parse_viewport);
match render::screenshot(&url, &out, viewport) {
Ok(()) => {
println!("{}", out.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("mock-render: {e}");
ExitCode::from(1)
}
}
}
fn cmd_lock(args: &[String]) -> ExitCode {
let (project, section) = match require_project_section(args) {
Ok(v) => v,
Err(e) => {
eprintln!("lock: {e}");
return ExitCode::from(1);
}
};
let screenshot = flag(args, "--screenshot");
let Ok(hash_now) = hash::hash_file(&section) else {
eprintln!("lock: cannot hash {}", section.display());
return ExitCode::from(2);
};
let mut st = match SiteState::load(&project) {
Ok(s) => s,
Err(e) => {
eprintln!("lock: {e}");
return ExitCode::from(2);
}
};
let key = SiteState::key_for(&section);
st.sections.insert(
key.clone(),
Section {
path: section.display().to_string(),
sha256: hash_now.clone(),
locked: true,
screenshot: screenshot.map(String::from),
},
);
if let Err(e) = st.save(&project) {
eprintln!("lock: {e}");
return ExitCode::from(2);
}
println!("locked {key} ({})", &hash_now[..12]);
ExitCode::SUCCESS
}
fn cmd_verify(args: &[String]) -> ExitCode {
let (project, section) = match require_project_section(args) {
Ok(v) => v,
Err(e) => {
eprintln!("verify: {e}");
return ExitCode::from(1);
}
};
let st = match SiteState::load(&project) {
Ok(s) => s,
Err(e) => {
eprintln!("verify: {e}");
return ExitCode::from(2);
}
};
let key = SiteState::key_for(&section);
let Some(entry) = st.sections.get(&key) else {
eprintln!("verify: section '{key}' not in site-state.json (not locked yet)");
return ExitCode::SUCCESS;
};
if !entry.locked {
return ExitCode::SUCCESS;
}
let Ok(hash_now) = hash::hash_file(&section) else {
eprintln!("verify: cannot hash {}", section.display());
return ExitCode::from(2);
};
if hash_now != entry.sha256 {
eprintln!(
"WYSIWYD VIOLATION: {key} drifted\n locked : {}\n current: {}\nThe screenshot user approved no longer matches the source.\nRerun render + user-approval before deploy.",
&entry.sha256[..12],
&hash_now[..12]
);
return ExitCode::from(2);
}
println!("ok {key} ({})", &hash_now[..12]);
ExitCode::SUCCESS
}
fn cmd_status(args: &[String]) -> ExitCode {
let project = flag(args, "--project")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let st = match SiteState::load(&project) {
Ok(s) => s,
Err(e) => {
eprintln!("status: {e}");
return ExitCode::from(2);
}
};
if st.sections.is_empty() {
println!("(no sections tracked)");
return ExitCode::SUCCESS;
}
for (name, sec) in &st.sections {
let lock = if sec.locked { "LOCKED" } else { "open" };
let drift = match hash::hash_file(Path::new(&sec.path)) {
Ok(h) if h == sec.sha256 => "clean",
Ok(_) => "DRIFT",
Err(_) => "missing",
};
println!(
"{:<20} {:>6} {:<7} {} ({})",
name,
lock,
drift,
sec.path,
&sec.sha256[..12]
);
}
ExitCode::SUCCESS
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.windows(2)
.find(|w| w[0] == name)
.map(|w| w[1].as_str())
}
fn parse_viewport(s: &str) -> Option<(u32, u32)> {
let (w, h) = s.split_once('x')?;
Some((w.parse().ok()?, h.parse().ok()?))
}
fn require_project_section(args: &[String]) -> Result<(PathBuf, PathBuf), String> {
let project = flag(args, "--project")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let section = flag(args, "--section")
.map(PathBuf::from)
.ok_or_else(|| "--section <file> required".to_string())?;
if !section.exists() {
return Err(format!("section file not found: {}", section.display()));
}
Ok((project, section))
}

View file

@ -0,0 +1,49 @@
//! Playwright subprocess wrapper (RULE 0.2 exception 6 — JS-only binding).
//! Calls `npx playwright screenshot` with clear error messages.
//!
//! Requires Node + `npx`. Playwright browsers installable via:
//! npx playwright install chromium
use std::path::Path;
use std::process::Command;
/// Render a URL (typically http://localhost:<port>/<page>) or a file:// URL
/// to a PNG via Playwright's CLI.
pub fn screenshot(url: &str, out: &Path, viewport: Option<(u32, u32)>) -> Result<(), String> {
let mut args = vec![
"--yes".to_string(),
"playwright".to_string(),
"screenshot".to_string(),
"--full-page".to_string(),
];
if let Some((w, h)) = viewport {
args.push("--viewport-size".to_string());
args.push(format!("{w},{h}"));
}
args.push(url.to_string());
args.push(out.display().to_string());
let output = Command::new("npx")
.args(&args)
.output()
.map_err(|e| format!("npx spawn: {e} — is Node installed?"))?;
if !output.status.success() {
return Err(format!(
"playwright screenshot failed (exit {}):\n{}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
));
}
if !out.exists() {
return Err(format!(
"playwright claimed success but {} was not written",
out.display()
));
}
Ok(())
}

View file

@ -0,0 +1,88 @@
//! site-state.json — single file that tracks which sections are locked
//! and what their approved SHA-256 is. One row per section.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_STATE: &str = "site-state.json";
#[derive(Serialize, Deserialize, Default)]
pub struct SiteState {
pub sections: BTreeMap<String, Section>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Section {
pub path: String,
pub sha256: String,
pub locked: bool,
pub screenshot: Option<String>,
}
impl SiteState {
pub fn load(project: &Path) -> Result<Self, String> {
let p = Self::path(project);
if !p.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(&p).map_err(|e| format!("read state: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("parse state: {e}"))
}
pub fn save(&self, project: &Path) -> Result<(), String> {
let p = Self::path(project);
let text = serde_json::to_string_pretty(self).map_err(|e| format!("serialize: {e}"))?;
fs::write(&p, text).map_err(|e| format!("write state: {e}"))
}
fn path(project: &Path) -> PathBuf {
project.join(DEFAULT_STATE)
}
pub fn key_for(section_path: &Path) -> String {
section_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_uses_file_stem() {
let k = SiteState::key_for(Path::new("src/sections/Hero.astro"));
assert_eq!(k, "Hero");
}
#[test]
fn load_missing_returns_default() {
let tmp = tempfile::tempdir().unwrap();
let st = SiteState::load(tmp.path()).unwrap();
assert!(st.sections.is_empty());
}
#[test]
fn save_and_reload_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let mut st = SiteState::default();
st.sections.insert(
"Hero".into(),
Section {
path: "src/sections/Hero.astro".into(),
sha256: "abcdef".repeat(10),
locked: true,
screenshot: Some("mocks/Hero.png".into()),
},
);
st.save(tmp.path()).unwrap();
let reloaded = SiteState::load(tmp.path()).unwrap();
assert_eq!(reloaded.sections.len(), 1);
assert!(reloaded.sections.get("Hero").unwrap().locked);
}
}

View file

@ -0,0 +1,16 @@
[package]
name = "tokens-sync"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "tokens-sync"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,127 @@
//! Tokens → Tailwind config (TypeScript) + CSS custom-property emission.
//! Emits stable, deterministically-ordered output (BTreeMap input guarantees).
use crate::parse::Tokens;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
pub fn tailwind_config(t: &Tokens, out: &Path) -> Result<(), String> {
let mut s = String::new();
s.push_str("// GENERATED by tokens-sync — do not edit by hand.\n");
s.push_str("import type { Config } from 'tailwindcss';\n\n");
s.push_str("const config: Config = {\n");
s.push_str(" content: [],\n");
s.push_str(" theme: {\n extend: {\n");
emit_category(&mut s, "colors", &t.colors);
emit_category(&mut s, "fontFamily", &t.fonts);
emit_category(&mut s, "spacing", &t.spacing);
emit_category(&mut s, "borderRadius", &t.radius);
s.push_str(" },\n },\n plugins: [],\n};\n\nexport default config;\n");
fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display()))
}
pub fn css_vars(t: &Tokens, out: &Path) -> Result<(), String> {
let mut s = String::new();
s.push_str("/* GENERATED by tokens-sync — do not edit by hand. */\n:root {\n");
for (k, v) in &t.colors {
let _ = writeln!(s, " --color-{}: {};", css_ident(k), v);
}
for (k, v) in &t.fonts {
let _ = writeln!(s, " --font-{}: {};", css_ident(k), v);
}
for (k, v) in &t.spacing {
let _ = writeln!(s, " --space-{}: {};", css_ident(k), v);
}
for (k, v) in &t.radius {
let _ = writeln!(s, " --radius-{}: {};", css_ident(k), v);
}
s.push_str("}\n");
fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display()))
}
fn emit_category(
s: &mut String,
key: &str,
map: &std::collections::BTreeMap<String, String>,
) {
if map.is_empty() {
return;
}
let _ = writeln!(s, " {key}: {{");
for (k, v) in map {
// Escape JS single quotes inside the value string.
let escaped = v.replace('\'', "\\'");
let _ = writeln!(s, " '{k}': '{escaped}',");
}
s.push_str(" },\n");
}
fn css_ident(raw: &str) -> String {
raw.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn sample_tokens() -> Tokens {
let mut colors = BTreeMap::new();
colors.insert("primary".into(), "oklch(0.6 0.2 250)".into());
colors.insert("surface".into(), "oklch(0.99 0 0)".into());
let mut fonts = BTreeMap::new();
fonts.insert("body".into(), "Inter, sans-serif".into());
let mut spacing = BTreeMap::new();
spacing.insert("md".into(), "1rem".into());
let mut radius = BTreeMap::new();
radius.insert("card".into(), "0.75rem".into());
Tokens { colors, fonts, spacing, radius }
}
#[test]
fn tailwind_emits_extend_categories() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("tw.ts");
tailwind_config(&sample_tokens(), &out).unwrap();
let text = fs::read_to_string(&out).unwrap();
assert!(text.contains("colors: {"));
assert!(text.contains("'primary': 'oklch(0.6 0.2 250)'"));
assert!(text.contains("fontFamily: {"));
assert!(text.contains("borderRadius: {"));
}
#[test]
fn css_emits_vars_under_root() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("tokens.css");
css_vars(&sample_tokens(), &out).unwrap();
let text = fs::read_to_string(&out).unwrap();
assert!(text.contains(":root {"));
assert!(text.contains("--color-primary: oklch(0.6 0.2 250);"));
assert!(text.contains("--font-body: Inter, sans-serif;"));
assert!(text.contains("--space-md: 1rem;"));
assert!(text.contains("--radius-card: 0.75rem;"));
}
#[test]
fn empty_tokens_still_emit_valid_shells() {
let dir = tempfile::tempdir().unwrap();
let t = Tokens::default();
let tw = dir.path().join("tw.ts");
let css = dir.path().join("c.css");
tailwind_config(&t, &tw).unwrap();
css_vars(&t, &css).unwrap();
let css_text = fs::read_to_string(&css).unwrap();
assert!(css_text.contains(":root {"));
assert!(css_text.trim_end().ends_with('}'));
}
}

View file

@ -0,0 +1,85 @@
//! tokens-sync — emit Tailwind config + CSS custom properties from a single
//! design-tokens JSON file. One SSoT; no drift between CSS/JS sides.
//!
//! USAGE
//! tokens-sync <tokens.json> --out-tailwind <path> --out-css <path>
//!
//! Input JSON shape (minimum):
//! {
//! "colors": { "primary": "oklch(0.6 0.2 250)", ... },
//! "fonts": { "display": "Fraunces Variable, serif", ... },
//! "spacing": { "sm": "0.5rem", ... },
//! "radius": { "card": "0.75rem", ... }
//! }
//!
//! At least one of --out-tailwind or --out-css must be supplied.
mod emit;
mod parse;
use std::env;
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
print_help();
return ExitCode::SUCCESS;
}
let Some(input) = args.iter().find(|a| !a.starts_with("--")).cloned() else {
eprintln!("tokens-sync: <tokens.json> required");
print_help();
return ExitCode::from(1);
};
let tailwind = flag(&args, "--out-tailwind").map(PathBuf::from);
let css = flag(&args, "--out-css").map(PathBuf::from);
if tailwind.is_none() && css.is_none() {
eprintln!("tokens-sync: need at least one of --out-tailwind or --out-css");
return ExitCode::from(1);
}
let tokens = match parse::load(&PathBuf::from(&input)) {
Ok(t) => t,
Err(e) => {
eprintln!("tokens-sync: {e}");
return ExitCode::from(1);
}
};
if let Some(path) = tailwind {
if let Err(e) = emit::tailwind_config(&tokens, &path) {
eprintln!("tokens-sync: emit tailwind: {e}");
return ExitCode::from(1);
}
println!("tailwind -> {}", path.display());
}
if let Some(path) = css {
if let Err(e) = emit::css_vars(&tokens, &path) {
eprintln!("tokens-sync: emit css: {e}");
return ExitCode::from(1);
}
println!("css -> {}", path.display());
}
ExitCode::SUCCESS
}
fn print_help() {
println!(
"tokens-sync — design tokens JSON → Tailwind config + CSS vars
USAGE
tokens-sync <tokens.json> --out-tailwind <path> --out-css <path>
Write at least one of --out-tailwind or --out-css (both allowed).
JSON schema: see source for minimum shape."
);
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == name).map(|w| w[1].as_str())
}

View file

@ -0,0 +1,71 @@
//! JSON → Tokens model. Flat string-keyed maps per category (colors, fonts,
//! spacing, radius). Unknown categories are ignored; missing categories
//! default to empty.
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
#[derive(Deserialize, Default)]
pub struct Tokens {
#[serde(default)]
pub colors: BTreeMap<String, String>,
#[serde(default)]
pub fonts: BTreeMap<String, String>,
#[serde(default)]
pub spacing: BTreeMap<String, String>,
#[serde(default)]
pub radius: BTreeMap<String, String>,
}
pub fn load(path: &Path) -> Result<Tokens, String> {
let text = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parses_full_shape() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(
f,
r#"{{
"colors": {{ "primary": "oklch(0.6 0.2 250)", "surface": "oklch(0.995 0 0)" }},
"fonts": {{ "display": "Fraunces, serif", "body": "Inter, sans-serif" }},
"spacing": {{ "sm": "0.5rem", "md": "1rem" }},
"radius": {{ "card": "0.75rem" }}
}}"#
)
.unwrap();
let tokens = load(f.path()).unwrap();
assert_eq!(tokens.colors.len(), 2);
assert_eq!(tokens.colors.get("primary").unwrap(), "oklch(0.6 0.2 250)");
assert_eq!(tokens.fonts.get("body").unwrap(), "Inter, sans-serif");
assert_eq!(tokens.spacing.len(), 2);
assert_eq!(tokens.radius.len(), 1);
}
#[test]
fn missing_categories_default_empty() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let payload = r##"{ "colors": { "primary": "#000" } }"##;
f.write_all(payload.as_bytes()).unwrap();
let tokens = load(f.path()).unwrap();
assert_eq!(tokens.colors.len(), 1);
assert!(tokens.fonts.is_empty());
assert!(tokens.spacing.is_empty());
assert!(tokens.radius.is_empty());
}
#[test]
fn invalid_json_errors() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "not json").unwrap();
assert!(load(f.path()).is_err());
}
}

View file

@ -0,0 +1,15 @@
[package]
name = "visual-diff"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "visual-diff"
path = "src/main.rs"
[dependencies]
image = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,136 @@
//! PNG diff routine. Decodes both images, compares pixels with a per-channel
//! tolerance, writes a red-tinted overlay where pixels differ, and reports
//! mismatch percentage.
use image::{ImageBuffer, Rgba, RgbaImage};
use std::path::Path;
pub struct Report {
pub pct: f64,
pub diff_px: u64,
pub total_px: u64,
pub diff_png_written: bool,
}
const CHANNEL_TOLERANCE: u8 = 4;
pub fn compare(a: &Path, b: &Path, out: &Path) -> Result<Report, String> {
let img_a = image::open(a).map_err(|e| format!("open {}: {e}", a.display()))?.to_rgba8();
let img_b = image::open(b).map_err(|e| format!("open {}: {e}", b.display()))?.to_rgba8();
let (wa, ha) = img_a.dimensions();
let (wb, hb) = img_b.dimensions();
if (wa, ha) != (wb, hb) {
return Err(format!(
"dimension mismatch: a={wa}x{ha} b={wb}x{hb} (resize before comparing)"
));
}
let total_px = u64::from(wa) * u64::from(ha);
let mut diff_px: u64 = 0;
let mut overlay: RgbaImage = ImageBuffer::new(wa, ha);
for y in 0..ha {
for x in 0..wa {
let pa = img_a.get_pixel(x, y);
let pb = img_b.get_pixel(x, y);
if pixel_differs(pa, pb, CHANNEL_TOLERANCE) {
diff_px += 1;
overlay.put_pixel(x, y, Rgba([255, 0, 64, 200])); // red flag
} else {
// faded original to give context
let faded = Rgba([pa[0] / 3, pa[1] / 3, pa[2] / 3, 255]);
overlay.put_pixel(x, y, faded);
}
}
}
let pct = if total_px == 0 {
0.0
} else {
(diff_px as f64 / total_px as f64) * 100.0
};
let mut diff_png_written = false;
if diff_px > 0 {
overlay
.save(out)
.map_err(|e| format!("write {}: {e}", out.display()))?;
diff_png_written = true;
}
Ok(Report {
pct,
diff_px,
total_px,
diff_png_written,
})
}
fn pixel_differs(a: &Rgba<u8>, b: &Rgba<u8>, tol: u8) -> bool {
(0..4).any(|i| a[i].abs_diff(b[i]) > tol)
}
#[cfg(test)]
mod tests {
use super::*;
use image::ImageBuffer;
fn write_solid(path: &Path, w: u32, h: u32, color: [u8; 4]) {
let img: RgbaImage = ImageBuffer::from_pixel(w, h, Rgba(color));
img.save(path).unwrap();
}
#[test]
fn identical_images_match() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 16, 16, [255, 255, 255, 255]);
write_solid(&b, 16, 16, [255, 255, 255, 255]);
let r = compare(&a, &b, &out).unwrap();
assert_eq!(r.diff_px, 0);
assert_eq!(r.total_px, 256);
assert!(!r.diff_png_written);
}
#[test]
fn fully_different_images() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 8, 8, [0, 0, 0, 255]);
write_solid(&b, 8, 8, [255, 255, 255, 255]);
let r = compare(&a, &b, &out).unwrap();
assert_eq!(r.diff_px, 64);
assert_eq!(r.total_px, 64);
assert!((r.pct - 100.0).abs() < 1e-6);
assert!(r.diff_png_written);
}
#[test]
fn dimension_mismatch_errors() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 8, 8, [0, 0, 0, 255]);
write_solid(&b, 16, 16, [0, 0, 0, 255]);
assert!(compare(&a, &b, &out).is_err());
}
#[test]
fn tolerance_absorbs_tiny_delta() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 8, 8, [100, 100, 100, 255]);
write_solid(&b, 8, 8, [103, 103, 103, 255]); // within CHANNEL_TOLERANCE=4
let r = compare(&a, &b, &out).unwrap();
assert_eq!(r.diff_px, 0);
}
}

View file

@ -0,0 +1,83 @@
//! visual-diff — pixel-level PNG comparator for WYSIWYD drift detection.
//!
//! USAGE
//! visual-diff <a.png> <b.png> [--out diff.png] [--threshold 5]
//!
//! Exit codes:
//! 0 images equal (within threshold)
//! 1 usage error
//! 2 images differ beyond threshold
//!
//! Prints percentage of mismatched pixels to stdout. Writes a red-overlay
//! diff PNG to <out> (default: ./diff.png) when images differ.
mod diff;
use std::env;
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
print_help();
return ExitCode::SUCCESS;
}
let positional: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if positional.len() < 2 {
eprintln!("visual-diff: need <a.png> <b.png>");
print_help();
return ExitCode::from(1);
}
let a = PathBuf::from(positional[0]);
let b = PathBuf::from(positional[1]);
let out = flag(&args, "--out")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("diff.png"));
let threshold: f64 = flag(&args, "--threshold")
.and_then(|s| s.parse().ok())
.unwrap_or(1.0);
match diff::compare(&a, &b, &out) {
Ok(report) => {
println!("{:.4}% differ ({} px of {})", report.pct, report.diff_px, report.total_px);
if report.diff_png_written {
eprintln!("wrote diff: {}", out.display());
}
if report.pct > threshold {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
}
}
Err(e) => {
eprintln!("visual-diff: {e}");
ExitCode::from(1)
}
}
}
fn print_help() {
println!(
"visual-diff — pixel-level PNG comparator
USAGE
visual-diff <a.png> <b.png> [--out diff.png] [--threshold 5]
OPTIONS
--out FILE write red-overlay diff PNG (default: diff.png)
--threshold PCT fail (exit 2) if mismatch exceeds PCT%% (default: 1.0)
EXIT
0 equal (within threshold)
1 usage / IO error
2 differ beyond threshold"
);
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == name).map(|w| w[1].as_str())
}