saccade 0.2.3

Visual-regression CLI for renderers and graphics code, built on NVIDIA FLIP
//! Product tool adapter with startup-only network and notification authority.
use super::{Server, ToolOutput, ToolResult, arg_str, reject_unknown, require_str};
use crate::{
    agent::CliError, design_cmd as design, imgtune_cmd as tune, product_io as io,
    sweep_cmd as sweep,
};
use serde_json::{Map, Value, json};
use std::path::Path;

impl Server {
    pub(super) fn product_tool(&self, args: &Map<String, Value>) -> ToolResult {
        reject_unknown(
            args,
            &[
                "operation",
                "artifact",
                "out",
                "captures",
                "pull",
                "config",
                "before_origin",
                "after_origin",
                "seed",
                "samples",
                "viewports",
                "accept",
                "fixture_dir",
                "cache",
                "scale",
                "align",
                "resample",
                "baseline",
                "history_store",
                "template",
                "report_link",
            ],
        )?;
        let op = require_str(args, "operation")?;
        let file = |key: &str| -> Result<std::path::PathBuf, CliError> {
            self.existing_file(key, &require_str(args, key)?)
        };
        let output = || -> Result<std::path::PathBuf, CliError> {
            self.resolve("out", &require_str(args, "out")?)
        };
        let network = || {
            if self.product_network {
                Ok(())
            } else {
                Err(CliError::new(
                    "network_not_authorized",
                    "product HTTP requires human startup --allow-product-network",
                ))
            }
        };
        let config = || -> Result<saccade_core::config::RunConfig, CliError> {
            if let Some(path) = arg_str(args, "config")? {
                let path = self.existing_file("config", &path)?;
                let config = crate::load_config(Some(&path))?;
                self.validate_config(&config)?;
                Ok(config)
            } else {
                Ok(saccade_core::config::RunConfig::default())
            }
        };
        let value = match op.as_str() {
            "sweep_plan" => {
                let artifact = file("artifact")?;
                let urls = String::from_utf8(io::read(&artifact)?)
                    .map_err(|_| CliError::usage("URL list must be UTF-8"))?
                    .lines()
                    .map(str::trim)
                    .filter(|s| !s.is_empty() && !s.starts_with('#'))
                    .map(str::to_owned)
                    .collect();
                let viewports: Vec<[u32; 2]> = args
                    .get("viewports")
                    .map(|v| serde_json::from_value(v.clone()))
                    .transpose()?
                    .unwrap_or_else(|| vec![[1280, 720]]);
                let manifest = sweep::plan(
                    urls,
                    &require_str(args, "before_origin")?,
                    &require_str(args, "after_origin")?,
                    args.get("samples").and_then(Value::as_u64).unwrap_or(3) as usize,
                    args.get("seed").and_then(Value::as_u64).unwrap_or(42),
                    &viewports,
                )?;
                io::write(&output()?, &manifest)?;
                serde_json::to_value(manifest)?
            }
            "sweep_compare" => {
                let artifact = file("artifact")?;
                let captures = file("captures")?;
                self.document_inputs(&captures)?;
                let last = if arg_str(args, "baseline")?.as_deref() == Some("last-good") {
                    Some(self.product_last_good(args)?)
                } else {
                    None
                };
                let align = arg_str(args, "align")?;
                let resample = arg_str(args, "resample")?;
                if align.is_some() || resample.is_some() {
                    if args.contains_key("config") || align.is_none() {
                        return Err(CliError::usage(
                            "registration requires align and refuses comparison config",
                        ));
                    }
                    use clap::ValueEnum;
                    let options = crate::general_cmd::CompareArgs {
                        align: Some(
                            crate::general_cmd::Align::from_str(
                                align.as_deref().unwrap_or(""),
                                false,
                            )
                            .map_err(CliError::usage)?,
                        ),
                        resample: resample
                            .as_deref()
                            .map(|s| {
                                crate::general_cmd::Resample::from_str(s, false)
                                    .map_err(CliError::usage)
                            })
                            .transpose()?,
                        ..Default::default()
                    };
                    sweep::compare_with_registration(
                        &artifact,
                        &captures,
                        &output()?,
                        &config()?,
                        last.as_ref().map(|t| t.path()),
                        Some(options),
                    )?
                } else {
                    sweep::compare(
                        &artifact,
                        &captures,
                        &output()?,
                        &config()?,
                        last.as_ref().map(|t| t.path()),
                    )?
                }
            }
            "last_good_compare" => {
                let captures = self.existing_dir("captures", &require_str(args, "captures")?)?;
                let last = self.product_last_good(args)?;
                let out = output()?;
                let report = saccade_core::run::run(last.path(), &captures, &out, &config()?)?;
                crate::agent::result_value(
                    &report,
                    &out.join(saccade_core::report::REPORT_FILE_NAME),
                    5,
                    false,
                )
            }
            "imgtune_audit" => {
                network()?;
                let artifact = file("artifact")?;
                let urls = String::from_utf8(io::read(&artifact)?)
                    .map_err(|_| CliError::usage("URL list must be UTF-8"))?
                    .lines()
                    .map(str::trim)
                    .filter(|s| !s.is_empty() && !s.starts_with('#'))
                    .map(str::to_owned)
                    .collect::<Vec<_>>();
                let accepts: Vec<String> = serde_json::from_value(
                    args.get("accept")
                        .cloned()
                        .ok_or_else(|| CliError::usage("Accept headers required"))?,
                )?;
                let value = tune::audit(&urls, &accepts)?;
                io::write(&output()?, &value)?;
                value
            }
            "imgtune_search" => {
                let artifact = file("artifact")?;
                let input: tune::Search = io::json(&artifact)?;
                let root = artifact.parent().unwrap_or(Path::new("."));
                if matches!(input.adapter, tune::Adapter::UrlTemplate { .. }) {
                    network()?;
                }
                for image in &input.images {
                    for path in [&image.source, &image.current] {
                        if path.starts_with("http://") || path.starts_with("https://") {
                            network()?;
                        } else {
                            self.policy.read(&root.join(path)).map_err(|_| {
                                CliError::new(
                                    "unsafe_path",
                                    "image tuning source outside registered roots",
                                )
                            })?;
                        }
                    }
                }
                let value = tune::search(input, root)?;
                io::write(&output()?, &value)?;
                value
            }
            "design_pull" => {
                let artifact = file("artifact")?;
                let map = design::mapping(&artifact)?;
                let out = output()?;
                let cache = self.resolve("out", &require_str(args, "cache")?)?;
                let scale = args.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
                let pull = if let Some(root) = arg_str(args, "fixture_dir")? {
                    let root = self.existing_dir("fixture_dir", &root)?;
                    for frame in &map.frames {
                        for kind in ["file", "styles", "variables", "images"] {
                            self.policy
                                .read(&root.join(&frame.file_key).join(format!("{kind}.json")))
                                .map_err(|_| {
                                    CliError::new(
                                        "unsafe_path",
                                        "design fixture outside registered roots",
                                    )
                                })?;
                        }
                        self.policy
                            .read(&root.join("images").join(format!(
                                "{}.png",
                                design::frame_id(&frame.file_key, &frame.node_id)
                            )))
                            .map_err(|_| {
                                CliError::new(
                                    "unsafe_path",
                                    "design image fixture outside registered roots",
                                )
                            })?;
                    }
                    design::pull(
                        &map,
                        &out,
                        &cache,
                        scale,
                        &design::Fixtures { root },
                        "recorded_fixture",
                    )?
                } else {
                    network()?;
                    design::pull_live(&map, &out, &cache, scale)?
                };
                serde_json::to_value(pull)?
            }
            "design_compare" => {
                let artifact = file("artifact")?;
                let pull = file("pull")?;
                let captures = file("captures")?;
                self.document_inputs(&pull)?;
                self.document_inputs(&captures)?;
                let align = arg_str(args, "align")?.unwrap_or_else(|| "translation".into());
                if !matches!(align.as_str(), "translation" | "none") {
                    return Err(CliError::usage(
                        "design alignment must be none or translation",
                    ));
                }
                design::compare(&artifact, &pull, &captures, &output()?, &config()?, &align)?
            }
            "notify" => {
                if !self.product_notifications {
                    return Err(CliError::new(
                        "notification_not_authorized",
                        "webhooks require explicit human startup --allow-webhook-notifications",
                    ));
                }
                let artifact = file("artifact")?;
                let report: Value = io::json(&artifact)?;
                let template = match arg_str(args, "template")?.as_deref().unwrap_or("generic") {
                    "generic" => crate::notifier_cmd::Template::Generic,
                    "slack" => crate::notifier_cmd::Template::Slack,
                    "teams" => crate::notifier_cmd::Template::Teams,
                    _ => return Err(CliError::usage("unknown webhook template")),
                };
                crate::notifier_cmd::send_configured(
                    &crate::notifier_cmd::summary(
                        &report,
                        &artifact,
                        arg_str(args, "report_link")?.as_deref(),
                    )?,
                    template,
                )?
            }
            _ => return Err(CliError::usage("unknown product operation")),
        };
        // Full evidence stays in artifacts. Returned summaries stay within MCP's
        // normal bounded envelope; provider text never becomes an executable action.
        let mut result = crate::local_cmd::base_result(&op);
        result["measurement"] = json!(match value["verdict"].as_str() {
            Some("pass" | "complete") => "pass",
            Some("regression" | "incomplete") => "regression",
            _ => "unknown",
        });
        result["data"] = if serde_json::to_vec(&value)?.len() <= 6000 {
            value.clone()
        } else {
            json!({"schema":value["schema"],"verdict":value["verdict"],"full_evidence":"written artifact","items":value["items"].as_array().map(Vec::len),"frames":value["frames"].as_array().map(Vec::len),"capture_failures":value["capture_failures"].as_array().map(Vec::len)})
        };
        if let Some(out) = arg_str(args, "out")? {
            let out = self.resolve("out", &out)?;
            let artifact = match op.as_str() {
                "sweep_compare" => out.join("saccade-sweep-report.v1.json"),
                "design_pull" => out.join("pull.json"),
                "design_compare" => out.join("saccade-design-report.v1.json"),
                "last_good_compare" => out.join(saccade_core::report::REPORT_FILE_NAME),
                _ => out,
            };
            result["artifact"] = crate::local_cmd::reference(&artifact)?;
        }
        let result = crate::local_cmd::bounded(result, 8192)?;
        Ok(ToolOutput {
            structured: result,
            text: format!(
                "Product operation {op}; inspect the written artifact for complete evidence."
            ),
            images: vec![],
        })
    }
    fn product_last_good(&self, args: &Map<String, Value>) -> Result<tempfile::TempDir, CliError> {
        let store = self.existing_dir("history_store", &require_str(args, "history_store")?)?;
        crate::last_good::resolve_checked(&store, |path| {
            self.policy.read(path).map(|_| ()).map_err(|_| {
                CliError::new("unsafe_path", "last-good capture outside registered roots")
            })
        })
    }
}
pub(super) fn schema() -> Value {
    let mut tool = json!({"name":"saccade_products","description":"Wave 5 planning, tuning, design and last-good. Inputs remain inside registered roots, outputs require out-root; HTTP and notifications require independent human startup authorization.","inputSchema":{"type":"object","properties":{"operation":{"enum":["sweep_plan","sweep_compare","last_good_compare","imgtune_audit","imgtune_search","design_pull","design_compare","notify"]},"artifact":{"type":"string"},"out":{"type":"string"},"captures":{"type":"string"},"pull":{"type":"string"},"config":{"type":"string"},"before_origin":{"type":"string"},"after_origin":{"type":"string"},"seed":{"type":"integer","minimum":0},"samples":{"type":"integer","minimum":1,"maximum":100},"viewports":{"type":"array","items":{"type":"array","items":{"type":"integer"},"minItems":2,"maxItems":2}},"accept":{"type":"array","items":{"type":"string"}},"fixture_dir":{"type":"string"},"cache":{"type":"string"},"scale":{"type":"number","minimum":0.01,"maximum":4},"align":{"enum":["none","translation","similarity","affine","homography","auto"]},"resample":{"enum":["reference","common"]},"baseline":{"const":"last-good"},"history_store":{"type":"string"},"template":{"enum":["generic","slack","teams"]},"report_link":{"type":"string"}},"required":["operation"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"schema":{"const":"saccade-result.v2"}},"required":["schema"]},"annotations":{"destructiveHint":false,"openWorldHint":true}});
    let base = tool["inputSchema"].clone();
    let variants: Vec<_> = base["properties"]["operation"]["enum"]
        .as_array()
        .into_iter()
        .flatten()
        .map(|operation| {
            let mut variant = base.clone();
            variant["properties"]["operation"] = json!({"type":"string","const":operation});
            variant
        })
        .collect();
    tool["inputSchema"] = json!({"type":"object","oneOf":variants});
    tool
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    #[test]
    fn tool_arguments_cannot_authorize_product_http_or_notifications() {
        let root = tempfile::tempdir().expect("root");
        let server = Server::new(&[root.path().to_path_buf()], None, false, &[]).expect("server");
        for (operation, code) in [
            ("imgtune_audit", "network_not_authorized"),
            ("notify", "notification_not_authorized"),
        ] {
            let args = json!({"operation":operation});
            let error = server
                .product_tool(args.as_object().expect("object"))
                .err()
                .expect("denied");
            assert_eq!(error.code, code);
        }
        let args = json!({"operation":"notify","allow_webhook_notifications":true});
        assert_eq!(
            server
                .product_tool(args.as_object().expect("object"))
                .err()
                .expect("unknown argument")
                .code,
            "usage"
        );
    }
}