saccade 0.2.9

Visual-regression CLI for renderers and graphics code, built on NVIDIA FLIP
//! Page-streamed document comparison using the existing registered measurement boundary.
use crate::{agent::CliError, general_cmd};
use saccade_core::general::{documents, input};
use serde_json::{Value, json};
use std::path::Path;

pub(crate) fn is_document(path: &Path) -> bool {
    path.extension()
        .and_then(|s| s.to_str())
        .is_some_and(|s| s.eq_ignore_ascii_case("svg") || s.eq_ignore_ascii_case("pdf"))
}
pub(crate) fn compare(
    a: &Path,
    b: &Path,
    out: &Path,
    options: &general_cmd::CompareArgs,
    threshold: f64,
    metric: crate::MetricArg,
    json_output: bool,
) -> Result<u8, CliError> {
    let value = measure(a, b, out, options, threshold, metric)?;
    general_cmd::emit_document_saved(
        &value,
        &out.join(format!("{}.json", documents::SCHEMA)),
        json_output,
    )
}
#[cfg(feature = "documents")]
pub(crate) mod operation;

pub(crate) fn measure(
    a: &Path,
    b: &Path,
    out: &Path,
    options: &general_cmd::CompareArgs,
    threshold: f64,
    metric: crate::MetricArg,
) -> Result<Value, CliError> {
    #[cfg(feature = "documents")]
    {
        operation::measure(a, b, out, options, threshold, metric)
    }
    #[cfg(not(feature = "documents"))]
    {
        measure_local(a, b, out, options, threshold, metric, out)
    }
}

pub(super) fn measure_local(
    a: &Path,
    b: &Path,
    out: &Path,
    options: &general_cmd::CompareArgs,
    threshold: f64,
    metric: crate::MetricArg,
    scratch: &Path,
) -> Result<Value, CliError> {
    if !cfg!(feature = "documents") {
        return Err(CliError::new(
            "feature_unavailable",
            "document input requires documents",
        ));
    }
    if !threshold.is_finite() || !(0. ..=1.).contains(&threshold) {
        return Err(CliError::usage("document FLIP threshold must be 0..1"));
    }
    let aa = input::bytes(a, input::MAX_BYTES)?;
    let bb = input::bytes(b, input::MAX_BYTES)?;
    let ac = documents::page_count(&aa)?;
    let bc = documents::page_count(&bb)?;
    let dpi = options.dpi.unwrap_or(documents::DEFAULT_DPI);
    if !dpi.is_finite() || !(36. ..=600.).contains(&dpi) {
        return Err(CliError::usage("document DPI must be 36..600"));
    }
    let a_hash = saccade_core::localized::digest(&aa);
    let b_hash = saccade_core::localized::digest(&bb);
    let (map, map_source) = if let Some(path) = &options.page_map {
        let map: documents::page_map::Map =
            serde_json::from_slice(&input::bytes(path, 256 * 1024)?).map_err(|_| {
                CliError::new("document_page_map_invalid", "invalid declared page map")
            })?;
        map.validate(ac, bc, &a_hash, &b_hash)?;
        (map, "declared")
    } else if ac == 1 && bc == 1 {
        (
            documents::page_map::Map {
                schema: documents::page_map::SCHEMA.into(),
                reference_sha256: a_hash.clone(),
                candidate_sha256: b_hash.clone(),
                pairs: vec![documents::page_map::Pair {
                    reference: Some(1),
                    candidate: Some(1),
                }],
            },
            "single_page",
        )
    } else {
        return Err(CliError::new(
            "document_page_map_required",
            "multipage comparison requires --page-map with complete, input-bound correspondence",
        ));
    };
    general_cmd::prepare_out(out, &[a, b])?;
    let mut pages = Vec::new();
    let mut failures = 0;
    for (page, pair) in map.pairs.iter().enumerate() {
        let (Some(reference), Some(candidate)) = (pair.reference, pair.candidate) else {
            failures += 1;
            pages.push(json!({"page":page+1,"reference_page":pair.reference,"candidate_page":pair.candidate,"status":if pair.reference.is_none(){"new"}else{"missing"},"measurement":null}));
            continue;
        };
        let load = |bytes: &[u8], page: usize| -> Result<image::RgbaImage, CliError> {
            let image = if documents::format(bytes).is_some() {
                documents::page(bytes, dpi, page)?
            } else {
                input::decode(bytes)?
            };
            documents::worker::charge_pixels(u64::from(image.width()) * u64::from(image.height()))?;
            Ok(image)
        };
        let result = (|| -> Result<Value, CliError> {
            let ap = scratch.join("a.png");
            let bp = scratch.join("b.png");
            save(&load(&aa, reference - 1)?, &ap)?;
            save(&load(&bb, candidate - 1)?, &bp)?;
            let page_options = general_cmd::CompareArgs {
                align: Some(options.align.unwrap_or(general_cmd::Align::None)),
                resample: options.resample,
                ..Default::default()
            };
            let sub = out.join(format!("page-{:04}", page + 1));
            let value =
                general_cmd::compare_document(&ap, &bp, &sub, &page_options, threshold, metric)?;
            general_cmd::persist_document(&value, &sub)?;
            Ok(value)
        })();
        match result {
            Ok(value) => {
                let status = value["verdict"].as_str().unwrap_or("error");
                if status != "pass" {
                    failures += 1;
                }
                pages.push(json!({"page":page+1,"reference_page":reference,"candidate_page":candidate,"status":status,"measurement":value,"artifact":format!("page-{:04}/saccade-registration.v1.json",page+1)}));
            }
            Err(error) => {
                if error.code == "document_total_pixel_limit"
                    || error.code == "document_output_byte_limit"
                {
                    return Err(error);
                }
                failures += 1;
                pages.push(json!({"page":page+1,"reference_page":reference,"candidate_page":candidate,"status":"error","error":{"code":error.code,"message":error.message}}));
            }
        }
    }
    let value = json!({"schema":documents::SCHEMA,"operation":"documents_compare","verdict":if failures==0{"pass"}else{"regression"},"counts":{"total":pages.len(),"failures":failures,"reference_pages":ac,"candidate_pages":bc},"inputs":{"a_sha256":a_hash,"b_sha256":b_hash},"page_map":{"source":map_source,"map":map},"caps":documents::worker::CAPS,"rendering":{"dpi":dpi,"svg":"resvg/usvg 0.48.1; CSS dimensions at 96 DPI","pdf":"hayro 0.3.0; PDF points at 72 DPI","alpha":"straight RGBA; white comparison background","resources":"offline; SVG text/images/active content refused; PDF interpreter warnings fail page"},"pages":pages,"limitations":["declared correspondence is user-supplied evidence, not semantic matching","PDF renderer does not implement every PDF feature; errors remain visible","raster equality establishes equality only at the declared density; originals are bound by encoded SHA-256"]});
    Ok(value)
}

#[cfg(feature = "mcp")]
pub(crate) fn schemas() -> Vec<Value> {
    vec![
        json!({"type":"object","properties":{"operation":{"const":"documents_compare","type":"string"},"reference":{"type":"string"},"capture":{"type":"string"},"out":{"type":"string"},"dpi":{"type":"number","minimum":36,"maximum":600},"threshold":{"type":"number","minimum":0,"maximum":1}},"required":["operation","reference","capture","out"],"additionalProperties":false}),
    ]
}

pub(crate) fn save(image: &impl PngSource, path: &Path) -> Result<(), CliError> {
    if !documents::worker::operation_is_active() {
        return image.save_unbounded(path);
    }
    let bytes = image.png()?;
    documents::worker::charge_output(bytes.len() as u64)?;
    std::fs::write(path, bytes).map_err(|e| CliError::io(e.to_string()))
}
pub(crate) trait PngSource {
    fn save_unbounded(&self, path: &Path) -> Result<(), CliError>;
    fn png(&self) -> Result<Vec<u8>, CliError>;
}
impl PngSource for image::RgbaImage {
    fn save_unbounded(&self, path: &Path) -> Result<(), CliError> {
        self.save(path).map_err(|e| CliError::io(e.to_string()))
    }
    fn png(&self) -> Result<Vec<u8>, CliError> {
        use image::ImageEncoder;
        let mut bytes = Vec::new();
        image::codecs::png::PngEncoder::new(&mut bytes)
            .write_image(
                self.as_raw(),
                self.width(),
                self.height(),
                image::ExtendedColorType::Rgba8,
            )
            .map_err(|e| CliError::io(e.to_string()))?;
        Ok(bytes)
    }
}
impl PngSource for image::DynamicImage {
    fn save_unbounded(&self, path: &Path) -> Result<(), CliError> {
        self.save(path).map_err(|e| CliError::io(e.to_string()))
    }
    fn png(&self) -> Result<Vec<u8>, CliError> {
        let mut bytes = std::io::Cursor::new(Vec::new());
        self.write_to(&mut bytes, image::ImageFormat::Png)
            .map_err(|e| CliError::io(e.to_string()))?;
        Ok(bytes.into_inner())
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod budget_tests {
    use super::*;
    #[test]
    fn parent_output_quota_refuses_before_png_and_report_index_write() {
        let dir = tempfile::tempdir().unwrap();
        let _budget = documents::worker::operation_budget();
        documents::worker::charge_output(documents::worker::CAPS.output_bytes - 1).unwrap();
        let image = image::RgbaImage::new(8, 8);
        let path = dir.path().join("refused.png");
        assert_eq!(
            save(&image, &path).unwrap_err().code,
            "document_output_byte_limit"
        );
        assert!(!path.exists());
        let report =
            saccade_core::report_links::decorate(&json!({"schema":documents::SCHEMA})).unwrap();
        let error = saccade_core::report_links::index(&dir.path().join("report.json"), &report)
            .unwrap_err();
        assert!(matches!(
            error,
            saccade_core::Error::Document {
                code: "document_output_byte_limit"
            }
        ));
        // Capability-bound append refuses before creating an index inode.
        let index = dir.path().join("reports/index.jsonl");
        assert!(!index.exists());
        std::fs::create_dir_all(index.parent().unwrap()).unwrap();
        std::fs::write(&index, b"retained index bytes\n").unwrap();
        let error = saccade_core::report_links::index(&dir.path().join("report.json"), &report)
            .unwrap_err();
        assert!(matches!(
            error,
            saccade_core::Error::Document {
                code: "document_output_byte_limit"
            }
        ));
        assert_eq!(std::fs::read(&index).unwrap(), b"retained index bytes\n");
    }
}