saccade 0.2.6

Visual-regression CLI for renderers and graphics code, built on NVIDIA FLIP
//! Additive root-confined MCP pre-check tools; no AI/network MCP option.

use crate::agent::CliError;
use serde_json::{Map, Value, json};
use std::path::PathBuf;

fn schema(id: &str) -> Value {
    saccade_core::evidence::legacy::schema(saccade_core::report_links::linked_schema(id))
        .and_then(|source| serde_json::from_str(&source).ok())
        .unwrap_or_else(|| json!({"type":"object"}))
}

pub(crate) fn schemas() -> Vec<Value> {
    vec![
        json!({"name":"saccade_safety","description":"Photosensitivity PRE-CHECK only. Not certification; does not replace Harding FPA/platform required testing or formal compliance. Deterministic numbered frames, or optional ffmpeg video.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"inputSchema":{"type":"object","additionalProperties":false,"properties":{"input":{"type":"string","minLength":1},"out_dir":{"type":"string","minLength":1},"fps":{"type":"number","exclusiveMinimum":0},"display":{"type":"string"},"standard":{"enum":["itu-bt1702","wcag"]},"junit":{"type":"string","minLength":1}},"required":["input","out_dir"]},"outputSchema":schema("saccade-safety.v1")}),
        json!({"name":"saccade_a11y","description":"Accessibility PRE-CHECK only: Machado colour-vision simulations, candidate information loss and contrast of confirmed config regions. Not certification or formal compliance. No network.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"inputSchema":{"type":"object","additionalProperties":false,"properties":{"input":{"type":"string","minLength":1},"out_dir":{"type":"string","minLength":1},"config":{"type":"string","minLength":1},"junit":{"type":"string","minLength":1}},"required":["input","out_dir"]},"outputSchema":schema("saccade-a11y.v1")}),
    ]
}
fn string(args: &Map<String, Value>, key: &str) -> Result<Option<String>, CliError> {
    match args.get(key) {
        None => Ok(None),
        Some(Value::String(s)) if !s.is_empty() => Ok(Some(s.clone())),
        _ => Err(CliError::usage(format!("{key} must be a nonempty string"))),
    }
}
fn required(args: &Map<String, Value>, key: &str) -> Result<String, CliError> {
    string(args, key)?.ok_or_else(|| CliError::usage(format!("missing {key}")))
}

pub(crate) fn call(
    name: &str,
    args: &Map<String, Value>,
    resolve: &impl Fn(&str, &str) -> Result<PathBuf, CliError>,
) -> Option<Result<(Value, String), CliError>> {
    if !matches!(name, "saccade_safety" | "saccade_a11y") {
        return None;
    }
    Some((|| {
        let allowed: &[&str] = if name == "saccade_safety" {
            &["input", "out_dir", "fps", "display", "standard", "junit"]
        } else {
            &["input", "out_dir", "config", "junit"]
        };
        if let Some(key) = args.keys().find(|k| !allowed.contains(&k.as_str())) {
            return Err(CliError::usage(format!("unknown argument {key}")));
        }
        let input = resolve("input", &required(args, "input")?)?;
        let out = resolve("out_dir", &required(args, "out_dir")?)?;
        let junit = string(args, "junit")?
            .map(|p| resolve("junit", &p))
            .transpose()?;
        if name == "saccade_safety" {
            let fps = args
                .get("fps")
                .map(|v| {
                    v.as_f64()
                        .ok_or_else(|| CliError::usage("fps must be a number"))
                })
                .transpose()?;
            let display = string(args, "display")?
                .as_deref()
                .map(saccade_core::safety::Display::parse)
                .transpose()?
                .unwrap_or_default();
            let standard = saccade_core::safety::Standard::parse(
                string(args, "standard")?.as_deref().unwrap_or("itu-bt1702"),
            )?;
            let report = saccade_core::safety::run(
                &input,
                &out,
                &saccade_core::safety::Options {
                    fps,
                    display,
                    standard,
                },
            )?;
            if let Some(path) = &junit {
                saccade_core::safety::output::junit(
                    path,
                    "saccade safety pre-check",
                    &report.junit_cases(),
                    &[&input],
                )?;
            }
            Ok((serde_json::to_value(&report)?, report.text()))
        } else {
            let config = string(args, "config")?
                .map(|p| resolve("config", &p))
                .transpose()?;
            let report = saccade_core::a11y::run(
                &input,
                &out,
                &saccade_core::a11y::Options {
                    config: config.clone(),
                    ..Default::default()
                },
            )?;
            if let Some(path) = &junit {
                let mut inputs = vec![input.as_path()];
                if let Some(c) = &config {
                    inputs.push(c.as_path());
                }
                saccade_core::safety::output::junit(
                    path,
                    "saccade accessibility pre-check",
                    &report.junit_cases(),
                    &inputs,
                )?;
            }
            Ok((serde_json::to_value(&report)?, report.text()))
        }
    })())
}