//! `wai conform <kind> --suite <suite> --impl <command or URL>` (operation
//! `conformance.run`): one of WAI's conformance corpora run against another implementation,
//! and the outcome written as a signed result record (`record`), which `wai verify` checks.
//!
//! The kinds and their suites are in `suites`; the two adapters (a command line, or an
//! `http://` URL) in `adapter`. Every case's expectation is the corpus's own. A run reads
//! the corpus from a checkout of the standard (`--root <wai-dir>`, or found from the
//! current directory), and records the digest of every corpus file it read, the
//! implementation it ran (the command and the SHA-256 of every file it names, or the URL),
//! each case's outcome, and the standard's version.
//!
//! Exit status: 0 when every case passed, 1 when one did not, 2 for a usage error, 4 when
//! the record would replace a file and `--yes` was not given, 5 when the suite, corpus
//! or program is not found, 6 when the implementation's URL does not answer, 7 otherwise.

mod adapter;
mod http;
pub mod record;
pub mod suites;

use super::envelope::{self, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_UNAVAILABLE, OpError};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use suites::{Case, Expect, Input, SUITES, Suite};

pub const OP: &str = "conformance.run";

const USAGE: &str = "wai conform <kind> --suite <suite> --impl <command or URL> [--key <file>] [--out <file>] [--yes]\n\
    [--root <wai-dir>] [--timeout <seconds>] [--name <text>] [--version <text>] [--dry-run] [--all] [--json|--text]\n\
    wai conform --list";

/// How the implementation is reached.
enum Impl {
    Command(String),
    Http(http::Url),
}

/// One case's outcome.
struct Outcome {
    name: String,
    pass: bool,
    expected: String,
    got: String,
}

fn cut(s: &str) -> String {
    if s.chars().count() > 300 { format!("{}…", s.chars().take(300).collect::<String>()) } else { s.to_string() }
}

/// The `wai/` directory of a checkout: `--root`, else the first directory up from here
/// that is one, or holds one.
pub(crate) fn wai_root(given: Option<&str>) -> Result<PathBuf, OpError> {
    let is_root = |d: &Path| d.join("SPEC.md").is_file() && d.join("registry").join("capabilities.json").is_file();
    if let Some(g) = given {
        let d = PathBuf::from(g);
        return if is_root(&d) {
            Ok(d)
        } else {
            Err(OpError::new(EXIT_NOT_FOUND, "no-corpus", format!("{g}: not the wai/ directory of a checkout of the standard"), "--root names the directory that holds SPEC.md and the *-conformance corpora"))
        };
    }
    let mut d = std::env::current_dir().map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?;
    loop {
        if is_root(&d) {
            return Ok(d);
        }
        if is_root(&d.join("wai")) {
            return Ok(d.join("wai"));
        }
        if !d.pop() {
            return Err(OpError::new(
                EXIT_NOT_FOUND,
                "no-corpus",
                "no checkout of the WAI standard here, so no corpus to run",
                "run it inside a checkout (git clone https://git.transaction.science/Transaction-Science/open-standards), or pass --root <checkout>/wai",
            ));
        }
    }
}

/// The standard's version: the first `## v…` heading of `CHANGELOG.md`.
fn standard_version(root: &Path) -> String {
    std::fs::read_to_string(root.join("CHANGELOG.md"))
        .ok()
        .and_then(|t| t.lines().find_map(|l| l.strip_prefix("## v").map(|r| format!("v{}", r.split_whitespace().next().unwrap_or("")))))
        .unwrap_or_else(|| "unknown".into())
}

/// Now, in RFC 3339 UTC to the second.
fn now_rfc3339() -> String {
    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}

/// The corpus digest (conformance-results §2.1) of the files a run read.
pub(crate) fn corpus_digest(root: &Path, files: &[PathBuf]) -> Result<String, OpError> {
    let mut read: Vec<(String, Vec<u8>)> = Vec::new();
    for f in files {
        let b = std::fs::read(f).map_err(|e| OpError::new(EXIT_NOT_FOUND, "corpus-file-missing", format!("{}: {e}", f.display()), "the corpus is incomplete; check out the standard again"))?;
        read.push((f.strip_prefix(root).unwrap_or(f).to_string_lossy().into_owned(), b));
    }
    Ok(wai::conform_result::corpus_digest(read.iter().map(|(p, b)| (p.as_str(), b.as_slice()))))
}

/// A packager's verdict line read: `ok <digest>`, or a refusal's reason, a leading copy of
/// the input's path ignored.
fn packager_tokens(line: &str, input: &Path) -> Vec<String> {
    let mut t: Vec<String> = line.split_whitespace().map(String::from).collect();
    if t.first().is_some_and(|w| Path::new(w) == input || input.file_name().is_some_and(|n| w.ends_with(&*n.to_string_lossy()))) {
        t.remove(0);
    }
    t
}

fn expected_text(e: &Expect) -> String {
    match e {
        Expect::Verdict { line, prefix: true } => format!("{line}…"),
        Expect::Verdict { line, .. } => line.clone(),
        Expect::Output { sha256 } => format!("output sha256 {sha256}"),
        Expect::Admit { digest } => format!("ok {digest}"),
        Expect::Refuse { reason } => reason.clone(),
        Expect::Object { cid, .. } => format!("{cid}.wai served as http-delivery §5.3 and §6.2 require"),
    }
}

fn judge(e: &Expect, got: &str, input: Option<&Path>) -> bool {
    match e {
        Expect::Verdict { line, prefix } => {
            if *prefix {
                got.starts_with(line.as_str())
            } else {
                got == line
            }
        }
        Expect::Output { sha256 } => got == format!("output sha256 {sha256}"),
        Expect::Admit { digest } => {
            let t = packager_tokens(got, input.unwrap_or(Path::new("")));
            t.len() >= 2 && t[0] == "ok" && t[1] == *digest
        }
        Expect::Refuse { reason } => packager_tokens(got, input.unwrap_or(Path::new(""))).first() == Some(reason),
        Expect::Object { .. } => got.is_empty(),
    }
}

/// What an origin serving the object answers, against http-delivery §5.3 and §6.2: the
/// problems found, none when it conforms.
fn check_object(url: &http::Url, cid: &str, bytes: &[u8], head_proof: Option<&[u8]>, timeout: Duration) -> Result<Vec<String>, http::Failure> {
    let mut p = Vec::new();
    let path = format!("/{cid}.wai");
    let r = http::request(url, "GET", &path, &[], None, timeout)?;
    if r.status != 200 {
        p.push(format!("GET {path}: status {} (200)", r.status));
    } else if r.body != bytes {
        p.push(format!("GET {path}: the body is not the object ({} bytes, sha256 {})", r.body.len(), &adapter::sha256_hex(&r.body)[..16]));
    }
    let want_etag = format!("\"{cid}\"");
    let h = |name: &str| r.header(name).unwrap_or_default();
    if h("ETag") != want_etag {
        p.push(format!("ETag {:?} ({want_etag})", h("ETag")));
    }
    if !h("Content-Type").starts_with("application/wai") {
        p.push(format!("Content-Type {:?} (application/wai)", h("Content-Type")));
    }
    let cc: Vec<String> = h("Cache-Control").split(',').map(|x| x.trim().to_ascii_lowercase()).collect();
    for want in ["public", "max-age=31536000", "immutable", "no-transform"] {
        if !cc.iter().any(|x| x == want) {
            p.push(format!("Cache-Control {:?} lacks {want}", h("Cache-Control")));
        }
    }
    if !h("Accept-Ranges").eq_ignore_ascii_case("bytes") {
        p.push(format!("Accept-Ranges {:?} (bytes)", h("Accept-Ranges")));
    }
    if h("Access-Control-Allow-Origin") != "*" {
        p.push(format!("Access-Control-Allow-Origin {:?} (*)", h("Access-Control-Allow-Origin")));
    }
    let expose = h("Access-Control-Expose-Headers").to_ascii_lowercase();
    for want in ["content-range", "content-length", "etag"] {
        if !expose.split(',').any(|x| x.trim() == want) {
            p.push(format!("Access-Control-Expose-Headers {:?} lacks {want}", h("Access-Control-Expose-Headers")));
        }
    }
    if h("Timing-Allow-Origin") != "*" {
        p.push(format!("Timing-Allow-Origin {:?} (*)", h("Timing-Allow-Origin")));
    }
    if r.header("Vary").is_some() {
        p.push(format!("Vary {:?} (an object is never selected by a request header)", h("Vary")));
    }
    if r.header("Content-Encoding").is_some_and(|e| !e.eq_ignore_ascii_case("identity")) {
        p.push(format!("Content-Encoding {:?} (none)", h("Content-Encoding")));
    }
    // A single byte range: 206 with the bytes and the whole length, or 200 with the object.
    let last = bytes.len().min(32).saturating_sub(1);
    let range = format!("bytes=0-{last}");
    let r = http::request(url, "GET", &path, &[("Range", &range)], None, timeout)?;
    match r.status {
        206 => {
            let want = format!("bytes 0-{last}/{}", bytes.len());
            if r.header("Content-Range").as_deref() != Some(want.as_str()) {
                p.push(format!("Range {range}: Content-Range {:?} ({want})", r.header("Content-Range")));
            }
            if r.body != bytes[..=last] {
                p.push(format!("Range {range}: the body is not those bytes"));
            }
        }
        200 if r.body == bytes => {}
        s => p.push(format!("Range {range}: status {s} (206 with the bytes, or 200 with the object)")),
    }
    let r = http::request(url, "HEAD", &path, &[], None, timeout)?;
    if r.status != 200 || r.header("ETag").as_deref() != Some(want_etag.as_str()) {
        p.push(format!("HEAD {path}: status {}, ETag {:?}", r.status, r.header("ETag")));
    }
    let r = http::request(url, "OPTIONS", &path, &[("Origin", "https://conformance.example"), ("Access-Control-Request-Method", "GET"), ("Access-Control-Request-Headers", "range, priority")], None, timeout)?;
    let allow = r.header("Access-Control-Allow-Headers").unwrap_or_default().to_ascii_lowercase();
    for want in ["range", "priority"] {
        if !allow.split(',').any(|x| x.trim() == want) {
            p.push(format!("preflight: Access-Control-Allow-Headers {allow:?} lacks {want}"));
        }
    }
    if r.header("Access-Control-Max-Age").is_none() {
        p.push("preflight: no Access-Control-Max-Age".into());
    }
    if let Some(hp) = head_proof {
        let hpath = format!("/{cid}.wai.hp");
        let r = http::request(url, "GET", &hpath, &[], None, timeout)?;
        if r.status != 200 || r.body != hp {
            p.push(format!("GET {hpath}: status {}, {} bytes (the head proof, {} bytes)", r.status, r.body.len(), hp.len()));
        } else if r.header("Content-Type").as_deref() != Some("application/octet-stream") {
            p.push(format!("GET {hpath}: Content-Type {:?} (application/octet-stream)", r.header("Content-Type")));
        }
    }
    Ok(p)
}

struct Run<'a> {
    imp: &'a Impl,
    timeout: Duration,
    scratch: PathBuf,
}

impl Run<'_> {
    fn http_failure(&self, f: http::Failure) -> Result<String, OpError> {
        match f {
            http::Failure::Unreachable(e) => Err(OpError::new(EXIT_UNAVAILABLE, "implementation-unreachable", format!("the implementation does not answer: {e}"), "start it, then run again")),
            http::Failure::Broken(e) => Ok(format!("no answer: {e}")),
        }
    }

    /// One case's answer, as the text `judge` reads.
    fn answer(&self, case: &Case) -> Result<String, OpError> {
        let internal = |e: String| OpError::new(EXIT_INTERNAL, "internal", e, "");
        match (self.imp, &case.input, &case.expect) {
            (Impl::Http(url), _, Expect::Object { cid, bytes_file, head_proof }) => {
                let bytes = std::fs::read(bytes_file).map_err(|e| internal(e.to_string()))?;
                let hp = head_proof.as_ref().and_then(|p| std::fs::read(p).ok());
                match check_object(url, cid, &bytes, hp.as_deref(), self.timeout) {
                    Ok(problems) => Ok(problems.join("; ")),
                    Err(f) => self.http_failure(f),
                }
            }
            (Impl::Http(url), input, expect) => {
                let (body, ctype) = match input {
                    Input::Json(v) => (v.to_string().into_bytes(), "application/json"),
                    Input::File(p) => (std::fs::read(p).map_err(|e| internal(format!("{}: {e}", p.display())))?, "application/octet-stream"),
                    Input::Served => (Vec::new(), "application/octet-stream"),
                };
                match http::request(url, "POST", "", &[("Content-Type", ctype)], Some(&body), self.timeout) {
                    Err(f) => self.http_failure(f),
                    Ok(r) => Ok(match expect {
                        Expect::Output { .. } if r.status == 200 => format!("output sha256 {}", adapter::sha256_hex(&r.body)),
                        Expect::Output { .. } => format!("refused ({}): {}", r.status, adapter::first_line(&r.body)),
                        _ => match serde_json::from_slice::<Value>(&r.body).ok().and_then(|v| v.get("verdict").and_then(Value::as_str).map(String::from)) {
                            Some(v) => v,
                            None => adapter::first_line(&r.body),
                        },
                    }),
                }
            }
            (Impl::Command(cmd), input, expect) => {
                let case_file = self.scratch.join("case.json");
                let out_file = self.scratch.join("out");
                let _ = std::fs::remove_file(&out_file);
                let mut with: Vec<(&str, &Path)> = vec![("out", out_file.as_path())];
                let mut stdin: Option<Vec<u8>> = None;
                match input {
                    Input::Json(v) => {
                        if cmd.contains("{case}") {
                            std::fs::write(&case_file, v.to_string()).map_err(|e| internal(e.to_string()))?;
                            with.push(("case", case_file.as_path()));
                        } else {
                            stdin = Some(v.to_string().into_bytes());
                        }
                    }
                    Input::File(p) => {
                        if cmd.contains("{in}") {
                            with.push(("in", p.as_path()));
                        } else {
                            stdin = Some(std::fs::read(p).map_err(|e| internal(format!("{}: {e}", p.display())))?);
                        }
                    }
                    Input::Served => {}
                }
                let line = adapter::fill(cmd, &with);
                let ran = adapter::run(&line, stdin.as_deref(), self.timeout).map_err(internal)?;
                if ran.timed_out {
                    return Ok(format!("timed out after {:?}", self.timeout));
                }
                Ok(match expect {
                    Expect::Output { .. } => {
                        if ran.code != Some(0) {
                            format!("refused (exit {:?}): {}", ran.code, adapter::first_line(&ran.stdout))
                        } else if cmd.contains("{out}") {
                            match std::fs::read(&out_file) {
                                Ok(b) => format!("output sha256 {}", adapter::sha256_hex(&b)),
                                Err(_) => "no output written to {out}".into(),
                            }
                        } else {
                            format!("output sha256 {}", adapter::sha256_hex(&ran.stdout))
                        }
                    }
                    _ => {
                        let l = adapter::first_line(&ran.stdout);
                        if l.is_empty() && ran.code != Some(0) {
                            format!("no verdict (exit {:?}): {}", ran.code, adapter::first_line(&ran.stderr))
                        } else {
                            l
                        }
                    }
                })
            }
        }
    }

    /// Every case, the whole corpus given to one run of the command (`{corpus}`), which
    /// answers `name<TAB>verdict` lines.
    fn whole_corpus(&self, cmd: &str, corpus: &Path, cases: &[Case]) -> Result<Vec<Outcome>, OpError> {
        let line = adapter::fill(cmd, &[("corpus", corpus)]);
        let ran = adapter::run(&line, None, self.timeout * 20).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?;
        let mut got: HashMap<String, String> = HashMap::new();
        for l in String::from_utf8_lossy(&ran.stdout).lines() {
            if let Some((n, v)) = l.split_once('\t') {
                got.entry(n.to_string()).or_insert_with(|| v.trim().to_string());
            }
        }
        let missing = if ran.timed_out { "timed out".to_string() } else { format!("no verdict (exit {:?}): {}", ran.code, adapter::first_line(&ran.stderr)) };
        Ok(cases
            .iter()
            .map(|c| {
                let g = got.get(&c.name).cloned().unwrap_or_else(|| missing.clone());
                Outcome { name: c.name.clone(), pass: judge(&c.expect, &g, None), expected: cut(&expected_text(&c.expect)), got: cut(&g) }
            })
            .collect())
    }
}

fn list(json_mode: bool, root: Option<&Path>) {
    let rows: Vec<Value> = SUITES
        .iter()
        .map(|s| {
            let n = root.and_then(|r| (s.load)(&r.join(s.dir)).ok()).map(|l| l.cases.len());
            let adapters: Vec<&str> = [s.command.then_some("command"), s.http.then_some("http")].into_iter().flatten().collect();
            json!({"kind": s.kind, "suite": s.name, "corpus": s.dir, "cases": n, "adapters": adapters, "about": s.about})
        })
        .collect();
    if json_mode {
        let mut m = Map::new();
        m.insert("suites".into(), Value::Array(rows));
        println!("{}", envelope::envelope("conformance.list", true, m));
        return;
    }
    for r in rows {
        println!(
            "{:<9} {:<7} {:<20} {:>5} cases  {:<13} {}",
            r["kind"].as_str().unwrap_or(""),
            r["suite"].as_str().unwrap_or(""),
            r["corpus"].as_str().unwrap_or(""),
            r["cases"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
            r["adapters"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("+")).unwrap_or_default(),
            r["about"].as_str().unwrap_or("")
        );
    }
}

pub fn main(args: &[String]) -> Result<bool, String> {
    let json_mode = envelope::json_wanted(args);
    match run(args, json_mode) {
        Ok(true) => Ok(true),
        Ok(false) => envelope::exit(EXIT_FAILED),
        Err(e) => envelope::fail(OP, json_mode, &e),
    }
}

fn run(args: &[String], json_mode: bool) -> Result<bool, OpError> {
    let flags = ["--suite", "--impl", "--key", "--out", "--root", "--timeout", "--name", "--version", "--dry-run", "--all", "--json", "--text", "--list"];
    let with_value = ["--suite", "--impl", "--key", "--out", "--root", "--timeout", "--name", "--version"];
    let pos = envelope::check_args(args, &flags, &with_value, 1, USAGE)?;
    let v = |f: &str| envelope::value(args, f, USAGE);
    if args.iter().any(|a| a == "--list") {
        let root = wai_root(v("--root")?).ok();
        list(json_mode, root.as_deref());
        return Ok(true);
    }
    let kind = *pos.first().ok_or_else(|| OpError::usage(format!("name a kind: {}", suites::KINDS.join(", ")), USAGE))?;
    if !suites::KINDS.contains(&kind) {
        return Err(OpError::new(EXIT_NOT_FOUND, "kind-not-found", format!("{kind}: no suite of that kind; the kinds are {}", suites::KINDS.join(", ")), "wai conform --list names every kind and suite"));
    }
    let of_kind: Vec<&Suite> = SUITES.iter().filter(|s| s.kind == kind).collect();
    let suite = match v("--suite")? {
        Some(n) => *of_kind.iter().find(|s| s.name == n).ok_or_else(|| {
            OpError::new(EXIT_NOT_FOUND, "suite-not-found", format!("{kind} has no suite {n}; it has {}", of_kind.iter().map(|s| s.name).collect::<Vec<_>>().join(", ")), "wai conform --list names every kind and suite")
        })?,
        None if of_kind.len() == 1 => of_kind[0],
        None => return Err(OpError::usage(format!("--suite: {kind} has {}", of_kind.iter().map(|s| s.name).collect::<Vec<_>>().join(", ")), USAGE)),
    };
    let imp_text = v("--impl")?.ok_or_else(|| OpError::usage("--impl <command or URL> is required: the implementation to run the corpus against", USAGE))?.to_string();
    let imp = if imp_text.starts_with("http://") || imp_text.starts_with("https://") {
        if !suite.http {
            return Err(OpError::usage(format!("{kind} {}: no HTTP adapter", suite.name), USAGE));
        }
        Impl::Http(http::Url::parse(&imp_text).map_err(|e| OpError::usage(e, USAGE))?)
    } else {
        if !suite.command {
            return Err(OpError::usage(format!("{kind} {}: an origin is reached at an http:// URL", suite.name), USAGE));
        }
        if !adapter::program_found(&imp_text) {
            return Err(OpError::new(EXIT_NOT_FOUND, "program-not-found", format!("{imp_text}: its program is not found"), "name a program on PATH, or a path to one"));
        }
        Impl::Command(imp_text.clone())
    };
    let timeout = Duration::from_secs(v("--timeout")?.map(|t| t.parse::<u64>().map_err(|_| OpError::usage("--timeout takes whole seconds", USAGE))).transpose()?.unwrap_or(60));
    let root = wai_root(v("--root")?)?;
    let loaded = (suite.load)(&root.join(suite.dir)).map_err(|e| OpError::new(EXIT_NOT_FOUND, "corpus-not-read", e, "the corpus is incomplete; check out the standard again"))?;
    if loaded.cases.is_empty() {
        return Err(OpError::new(EXIT_NOT_FOUND, "no-cases", format!("{}: no cases", suite.dir), ""));
    }
    let whole = matches!(&imp, Impl::Command(c) if c.contains("{corpus}"));
    if whole && loaded.corpus_file.is_none() {
        return Err(OpError::usage(format!("{kind} {}: {{corpus}} is for a verifier suite; this suite gives its cases one at a time", suite.name), USAGE));
    }
    let out = PathBuf::from(v("--out")?.map(String::from).unwrap_or_else(|| format!("{}{}", suite.name, record::SUFFIX)));
    if args.iter().any(|a| a == "--dry-run") {
        // The record it would write, in the envelope's plan.
        crate::out::would_write(&out);
        let first = &loaded.cases[0];
        let plan = match &imp {
            Impl::Command(c) if whole => adapter::fill(c, &[("corpus", loaded.corpus_file.as_deref().unwrap_or(Path::new("")))]),
            Impl::Command(c) => adapter::fill(c, &[("case", Path::new("<case.json>")), ("in", match &first.input {
                Input::File(p) => p.as_path(),
                _ => Path::new("<input>"),
            }), ("out", Path::new("<out>"))]),
            Impl::Http(u) => format!("{} {}", if matches!(first.expect, Expect::Object { .. }) { "GET, HEAD and OPTIONS below" } else { "POST each case to" }, u.join("")),
        };
        if json_mode {
            let mut m = Map::new();
            m.insert("kind".into(), json!(kind));
            m.insert("suite".into(), json!(suite.name));
            m.insert("dry_run".into(), json!(true));
            m.insert("cases".into(), json!(loaded.cases.iter().map(|c| c.name.clone()).collect::<Vec<_>>()));
            m.insert("runs".into(), json!(plan));
            m.insert("record".into(), json!(out.display().to_string()));
            println!("{}", envelope::envelope(OP, true, m));
        } else {
            println!("wai conform {kind} {}: {} cases from {}; would run: {plan}", suite.name, loaded.cases.len(), suite.dir);
            println!("dry run: nothing was run and nothing written; the record would be written to {}", out.display());
        }
        return Ok(true);
    }
    if out.exists() {
        // `--yes` (or its deprecated spelling `--confirm`), or yes at a terminal; otherwise exit 4
        // with the proposal and the confirming command.
        envelope::confirm(&format!("{} exists, and the record would replace it", out.display()), vec![json!({"write": out.display().to_string(), "replaces": true})])?;
    }
    let key = match v("--key")? {
        Some(p) => (record::key_from_file(Path::new(p)).map_err(|e| OpError::new(EXIT_NOT_FOUND, "key-not-read", e, "--key names a file holding a 32-byte Ed25519 seed in hexadecimal"))?, "supplied"),
        None => (record::ephemeral_key().map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?, "ephemeral"),
    };
    let digest = corpus_digest(&root, &loaded.files)?;
    let started = now_rfc3339();
    let scratch = std::env::temp_dir().join(format!("wai-conform-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)));
    std::fs::create_dir_all(&scratch).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?;
    let r = Run { imp: &imp, timeout, scratch: scratch.clone() };
    eprintln!("wai conform {kind} {}: {} cases from {}", suite.name, loaded.cases.len(), suite.dir);
    let outcomes: Result<Vec<Outcome>, OpError> = match (&imp, &loaded.corpus_file) {
        (Impl::Command(c), Some(f)) if whole => r.whole_corpus(c, f, &loaded.cases),
        _ => loaded
            .cases
            .iter()
            .map(|c| {
                let got = r.answer(c)?;
                let input = match &c.input {
                    Input::File(p) => Some(p.as_path()),
                    _ => None,
                };
                Ok(Outcome { name: c.name.clone(), pass: judge(&c.expect, &got, input), expected: cut(&expected_text(&c.expect)), got: cut(if matches!(c.expect, Expect::Object { .. }) && got.is_empty() { "served as required" } else { &got }) })
            })
            .collect(),
    };
    let _ = std::fs::remove_dir_all(&scratch);
    let outcomes = outcomes?;
    let passed = outcomes.iter().filter(|o| o.pass).count();
    let failed = outcomes.len() - passed;
    let conformant = failed == 0;
    let (adapter_name, files) = match &imp {
        Impl::Command(c) => ("command", adapter::identity(c).into_iter().map(|(p, h)| json!({"path": p, "sha256": h})).collect::<Vec<_>>()),
        Impl::Http(_) => ("http", Vec::new()),
    };
    let mut rec = Map::new();
    rec.insert("wai".into(), json!(record::FORMAT));
    rec.insert("kind".into(), json!(kind));
    rec.insert("suite".into(), json!(suite.name));
    rec.insert("standard".into(), json!(standard_version(&root)));
    rec.insert("toolchain".into(), json!({"name": "wai", "version": env!("CARGO_PKG_VERSION")}));
    rec.insert("corpus".into(), json!({"dir": suite.dir, "files": loaded.files.len(), "digest": digest}));
    rec.insert("implementation".into(), json!({"adapter": adapter_name, "impl": imp_text, "name": v("--name")?, "version": v("--version")?, "files": files}));
    rec.insert("started_at".into(), json!(started));
    rec.insert("cases".into(), Value::Array(outcomes.iter().map(|o| json!({"name": o.name, "pass": o.pass, "expected": o.expected, "got": o.got})).collect()));
    rec.insert("summary".into(), json!({"cases": outcomes.len(), "passed": passed, "failed": failed}));
    rec.insert("conformant".into(), json!(conformant));
    rec.insert("key".into(), json!(key.1));
    let rec = record::sign(rec, &key.0).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?;
    let text = serde_json::to_string_pretty(&Value::Object(rec.clone())).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?;
    crate::out::write(&out, format!("{text}\n")).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", format!("{}: {e}", out.display()), ""))?;
    let pubkey = rec.get("signer_pubkey").and_then(Value::as_str).unwrap_or("").to_string();
    if json_mode {
        let mut m = Map::new();
        m.insert("kind".into(), json!(kind));
        m.insert("suite".into(), json!(suite.name));
        m.insert("cases".into(), json!(outcomes.len()));
        m.insert("passed".into(), json!(passed));
        m.insert("failed".into(), json!(failed));
        m.insert("conformant".into(), json!(conformant));
        m.insert("failures".into(), Value::Array(outcomes.iter().filter(|o| !o.pass).take(200).map(|o| json!({"name": o.name, "expected": o.expected, "got": o.got})).collect()));
        m.insert("record".into(), json!(out.display().to_string()));
        m.insert("corpus_digest".into(), json!(rec["corpus"]["digest"]));
        m.insert("signer_pubkey".into(), json!(pubkey));
        println!("{}", envelope::envelope(OP, conformant, m));
    } else {
        let all = args.iter().any(|a| a == "--all");
        let mut shown = 0;
        for o in &outcomes {
            if o.pass && !all {
                continue;
            }
            if !o.pass {
                shown += 1;
                if shown > 40 && !all {
                    continue;
                }
            }
            println!("{} {}", if o.pass { "PASS" } else { "FAIL" }, o.name);
            if !o.pass {
                println!("     expected: {}", o.expected);
                println!("     got:      {}", o.got);
            }
        }
        if shown > 40 && !all {
            println!("… and {} more failures (--all lists every case)", shown - 40);
        }
        println!(
            "wai conform {kind} {}: {passed} of {} cases passed: {}",
            suite.name,
            outcomes.len(),
            if conformant { "conformant" } else { "not conformant" }
        );
        println!("record: {} (signed by {}…, {} key); `wai verify {}` checks it", out.display(), pubkey.get(..16).unwrap_or(""), key.1, out.display());
    }
    Ok(conformant)
}
