use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use saccade_core::config::RunConfig;
use saccade_core::report::{Labels, Metric, Mode, Report, Status};
use saccade_core::view::{ViewOptions, build_view, is_safe_name};
mod agent;
mod agent_ui;
mod approval;
mod engine_ingest;
mod f1;
mod git_bisect;
mod history;
mod ingest;
mod local_cmd;
#[cfg(feature = "ai")]
mod review_cmd;
#[cfg(feature = "mcp")]
mod mcp;
mod outdirs;
mod perf_cmd;
#[cfg(feature = "prechecks")]
mod precheck;
#[cfg(all(feature = "prechecks", feature = "mcp"))]
mod precheck_mcp;
#[cfg(feature = "graphics")]
mod temporal_cmd;
#[cfg(feature = "graphics")]
mod s6;
use agent::CliError;
const HELP_TEMPLATE: &str =
"{about-with-newline}\n{usage-heading} {usage}\n\n{after-help}\n\n{all-args}";
const FRONT_HELP_TEMPLATE: &str =
"{about-with-newline}\n{usage-heading} {usage}\n\n{all-args}\n{after-help}";
#[derive(Parser)]
#[command(
name = "saccade",
version = env!("SACCADE_DISPLAY_VERSION"),
disable_help_subcommand = true,
help_template = FRONT_HELP_TEMPLATE,
about = "Tell when visual or performance evidence is not good enough to support a claim",
after_help = "\
Start here:
saccade compare baseline/ captures/ --out report
saccade prove identity parent/ candidate/ --out proof
saccade prove performance --base 'base_r*' --arm 'candidate=candidate_r*'
saccade review report/saccade-report.v1.json --out review
Exit codes: 0 no image regression, 1 image regression found, 2 the command could not run.
Advanced: demo, identity, noise, view, inspect, experiment, approve, init,
serve, mcp, ingest, bisect, history, doctor. Existing commands keep working; use `saccade COMMAND --help`."
)]
struct Cli {
#[arg(long, global = true, help_heading = "Global options")]
allow_out_near_captures: bool,
#[arg(long, global = true, help_heading = "Global options")]
record_absolute_paths: bool,
#[command(subcommand)]
command: Command,
}
#[derive(clap::Args, Clone, Default)]
#[command(next_help_heading = "HDR images")]
struct HdrArgs {
#[arg(long, value_name = "NAME")]
hdr_tonemapper: Option<String>,
#[arg(long, value_name = "START:STOP:N", allow_hyphen_values = true)]
hdr_exposures: Option<String>,
}
impl HdrArgs {
fn apply(&self, hdr: &mut saccade_core::hdr::HdrConfig) -> Result<(), CliError> {
if let Some(t) = &self.hdr_tonemapper {
hdr.tonemapper = saccade_core::hdr::Tonemapper::parse(t)?;
}
if let Some(e) = &self.hdr_exposures {
hdr.parse_exposures(e)?;
}
Ok(())
}
}
#[derive(clap::Args, Clone, Default)]
#[command(next_help_heading = "Review context")]
struct IntentArgs {
#[arg(long, value_name = "TEXT", conflicts_with = "intent_file")]
intent: Option<String>,
#[arg(long, value_name = "FILE")]
intent_file: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
changes_file: Option<PathBuf>,
}
#[derive(clap::Args, Clone, Default)]
#[command(next_help_heading = "Metadata sidecars")]
struct MetaArgs {
#[arg(long, value_name = "NAME")]
meta_name: Option<String>,
#[arg(long, value_delimiter = ',', value_name = "GLOB,...")]
meta_ignore: Vec<String>,
}
#[derive(clap::Args, Clone, Default)]
#[command(next_help_heading = "Metadata sidecars")]
struct MetaRequireArgs {
#[arg(long)]
require_matching_meta: bool,
#[arg(
long,
value_delimiter = ',',
value_name = "KEY,...",
requires = "require_matching_meta"
)]
declare: Vec<String>,
}
impl MetaArgs {
fn apply(&self, meta: &mut saccade_core::meta::MetaOptions) {
if let Some(n) = &self.meta_name {
meta.name.clone_from(n);
}
meta.ignore.extend(self.meta_ignore.iter().cloned());
}
}
impl MetaRequireArgs {
fn apply(&self, meta: &mut saccade_core::meta::MetaOptions) {
meta.required |= self.require_matching_meta;
meta.declared.extend(self.declare.iter().cloned());
}
}
#[derive(Clone, Copy, ValueEnum)]
enum MetricArg {
Mean,
P95,
P99,
Max,
}
impl From<MetricArg> for Metric {
fn from(m: MetricArg) -> Self {
match m {
MetricArg::Mean => Metric::Mean,
MetricArg::P95 => Metric::P95,
MetricArg::P99 => Metric::P99,
MetricArg::Max => Metric::Max,
}
}
}
#[derive(Subcommand)]
enum Command {
#[command(hide = true)]
History(history::HistoryArgs),
#[command(hide = true)]
Bisect(git_bisect::BisectArgs),
#[command(hide = true)]
Ingest(ingest::IngestArgs),
#[command(display_order = 13, hide = true)]
Doctor {
#[arg(long)]
json: bool,
},
#[command(display_order = 7, hide = true)]
Init(f1::InitArgs),
#[command(
display_order = 1,
hide = true,
help_template = HELP_TEMPLATE,
after_help = "\
Example:
saccade demo --out saccade-demo
saccade view saccade-demo Print where the demo report is
The demo exits 1 on purpose: it contains a regression and a missing capture."
)]
Demo(f1::DemoArgs),
#[command(
display_order = 1,
help_template = HELP_TEMPLATE,
after_help = "\
Images are paired by relative path. Each pair gets a FLIP score; a pair fails when
its deciding metric is above the threshold. The report directory holds index.html
(open it in a browser) and saccade-report.v1.json.
Examples:
saccade compare baseline/ captures/ --out report
saccade compare baseline/ captures/ --threshold 0.02 --metric p95
saccade compare baseline/ captures/ --entry 'ui/*' --junit report/junit.xml
saccade compare baseline/ captures/ --json One bounded JSON result on stdout
Exit codes: 0 no regression, 1 regression found, 2 the command could not run."
)]
Compare {
baseline_dir: PathBuf,
capture_dir: PathBuf,
#[arg(long, default_value = "report", help_heading = "Output")]
out: PathBuf,
#[arg(long, help_heading = "Gate")]
threshold: Option<f64>,
#[arg(long, value_enum, help_heading = "Gate")]
metric: Option<MetricArg>,
#[arg(long, help_heading = "Gate")]
config: Option<PathBuf>,
#[arg(long, help_heading = "Gate")]
fail_on_new: bool,
#[arg(long, help_heading = "Gate")]
allow_empty: bool,
#[arg(long, help_heading = "Output")]
json: bool,
#[arg(long, help_heading = "Gate")]
ppd: Option<f32>,
#[arg(
long,
value_delimiter = ',',
value_name = "A,B",
help_heading = "Output"
)]
labels: Option<Vec<String>>,
#[command(flatten)]
hdr: HdrArgs,
#[arg(long = "entry", value_name = "GLOB", help_heading = "Selection")]
entries: Vec<String>,
#[arg(long, value_name = "FILE.xml", help_heading = "Output")]
junit: Option<PathBuf>,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
require: MetaRequireArgs,
#[command(flatten)]
perf: perf_cmd::PerfArgs,
#[command(flatten)]
intent: IntentArgs,
},
#[command(
display_order = 3,
hide = true,
help_template = HELP_TEMPLATE,
after_help = "\
Use it to prove a refactor or optimization renders the same pixels. There is no
threshold: any differing sample fails. Different file encodings of equal pixels pass.
Examples:
saccade identity parent/ candidate/ --out report
saccade identity parent/ candidate/ --json One bounded JSON result on stdout
Exit codes: 0 every pair identical, 1 identity not proven (a pair differs, is missing,
new or unreadable), 2 the command could not run."
)]
Identity {
parent_dir: PathBuf,
candidate_dir: PathBuf,
#[arg(long, default_value = "report", help_heading = "Output")]
out: PathBuf,
#[arg(long, help_heading = "Gate")]
allow_empty: bool,
#[arg(long, hide = true)]
threshold: Option<f64>,
#[arg(long, value_enum, hide = true)]
metric: Option<MetricArg>,
#[arg(long, help_heading = "Gate")]
config: Option<PathBuf>,
#[arg(long, help_heading = "Output")]
json: bool,
#[arg(long, help_heading = "Gate")]
ppd: Option<f32>,
#[arg(
long,
value_delimiter = ',',
value_name = "A,B",
help_heading = "Output"
)]
labels: Option<Vec<String>>,
#[arg(long = "entry", value_name = "GLOB", help_heading = "Selection")]
entries: Vec<String>,
#[arg(long, value_name = "FILE.xml", help_heading = "Output")]
junit: Option<PathBuf>,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
require: MetaRequireArgs,
#[command(flatten)]
perf: perf_cmd::PerfArgs,
#[command(flatten)]
intent: IntentArgs,
},
#[command(display_order = 2)]
Prove {
#[command(subcommand)]
operation: ProveOperation,
},
#[command(display_order = 8, hide = true)]
Noise(f1::NoiseArgs),
#[command(
display_order = 4,
hide = true,
help_template = HELP_TEMPLATE,
after_help = "\
Examples:
saccade view before/ after/ --out view Swipe, flicker and heatmap viewer
saccade view a/ b/ c/ --labels a,b,c --reference a
saccade view my-report Print where an existing report's page is
saccade view a/ b/ --blind --key-out ../key.json --out judge-view"
)]
View {
#[arg(num_args = 1..=6, required_unless_present = "unblind")]
dirs: Vec<PathBuf>,
#[arg(
long,
requires = "key",
conflicts_with = "dirs",
help_heading = "Blind judging"
)]
unblind: Option<PathBuf>,
#[arg(long, requires = "unblind", help_heading = "Blind judging")]
key: Option<PathBuf>,
#[arg(long, value_delimiter = ',', help_heading = "Output")]
labels: Option<Vec<String>>,
#[arg(long, help_heading = "Comparison")]
reference: Option<String>,
#[arg(long, help_heading = "Blind judging")]
blind: bool,
#[arg(long, help_heading = "Blind judging")]
seed: Option<u64>,
#[arg(
long,
value_name = "PATH",
requires = "blind",
help_heading = "Blind judging"
)]
key_out: Option<PathBuf>,
#[arg(long, default_value = "view", help_heading = "Output")]
out: PathBuf,
#[arg(long, help_heading = "Comparison")]
ppd: Option<f32>,
#[arg(long, help_heading = "Comparison")]
config: Option<PathBuf>,
#[arg(long, help_heading = "Output")]
json: bool,
#[command(flatten)]
hdr: HdrArgs,
#[arg(long = "entry", value_name = "GLOB", help_heading = "Selection")]
entries: Vec<String>,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
perf: perf_cmd::PerfArgs,
},
#[command(
display_order = 5,
hide = true,
help_template = HELP_TEMPLATE,
after_help = "\
Example (two steps: plan, then apply the reviewed decision):
saccade approve --report report/saccade-report.v1.json --entry ui.png --dry-run --out plan
saccade approve --report report/saccade-report.v1.json --decisions plan/decision.json --out receipt
Review the report, plan/manifest.json and plan/decision.json between the two steps.
The dry run writes no baseline; content hashes must still match when applying."
)]
Approve {
capture_dir: Option<PathBuf>,
baseline_dir: Option<PathBuf>,
#[arg(long)]
report: Option<PathBuf>,
names: Vec<String>,
#[arg(long = "entry", value_name = "NAME")]
entries: Vec<String>,
#[arg(long, value_name = "REPORT_JSON", num_args = 0..=1, default_missing_value = "__report__")]
all_failing: Option<PathBuf>,
#[arg(long, value_name = "DECISIONS_JSON")]
decisions: Option<PathBuf>,
#[arg(long)]
include_errors: bool,
#[arg(long)]
prune_missing: bool,
#[arg(long)]
json: bool,
#[arg(long, hide = true)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
out: Option<PathBuf>,
},
#[cfg(feature = "workbench")]
#[command(
display_order = 6,
hide = true,
help_template = HELP_TEMPLATE,
after_help = "\
The server listens on 127.0.0.1 only. Archive roots are read-only: sessions,
thumbnails and uploads go to the cache directory, decisions to the decisions directory.
Examples:
saccade serve captures/ --open Browse and compare runs in the browser
saccade serve captures/ reports/ --port 0 Several roots; pick a free port"
)]
Serve {
#[arg(num_args = 1..)]
roots: Vec<PathBuf>,
#[arg(long = "root")]
registered_roots: Vec<PathBuf>,
#[arg(long)]
out_root: Option<PathBuf>,
#[arg(long)]
follow_symlinks_within_roots: bool,
#[arg(long = "symlink-target")]
symlink_targets: Vec<PathBuf>,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
fs_timeout_ms: Option<u64>,
#[arg(long, default_value_t = 7878)]
port: u16,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
decisions_dir: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
ppd: Option<f32>,
#[arg(long)]
open: bool,
#[command(flatten)]
hdr: HdrArgs,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
perf: perf_cmd::PerfArgs,
},
#[cfg(feature = "mcp")]
#[command(
display_order = 12,
hide = true,
help_template = HELP_TEMPLATE,
after_help = "\
Every path a client passes must resolve under a --root. Generated reports go under
--out-root, which must be separate from the read-only roots.
Example:
saccade mcp --root examples --out-root agent-reports"
)]
Mcp {
#[arg(long = "root", required = true)]
roots: Vec<PathBuf>,
#[arg(long)]
out_root: Option<PathBuf>,
#[arg(long)]
follow_symlinks_within_roots: bool,
#[arg(long = "symlink-target", value_name = "DIR")]
symlink_targets: Vec<PathBuf>,
#[cfg(feature = "ai")]
#[command(flatten)]
providers: review_cmd::Startup,
},
#[command(display_order = 9, hide = true)]
Inspect(local_cmd::InspectArgs),
#[command(display_order = 3)]
Review(local_cmd::ReviewArgs),
#[command(display_order = 11, hide = true)]
Experiment {
#[command(subcommand)]
operation: ExperimentOperation,
},
}
#[derive(Subcommand)]
enum ProveOperation {
Identity(Box<ProveIdentityArgs>),
#[cfg(feature = "graphics")]
Performance(Box<perf_cmd::AblateArgs>),
}
#[derive(clap::Args)]
struct ProveIdentityArgs {
parent_dir: PathBuf,
candidate_dir: PathBuf,
#[arg(long, default_value = "report")]
out: PathBuf,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
allow_empty: bool,
#[arg(long)]
ppd: Option<f32>,
#[arg(long, value_delimiter = ',', value_name = "A,B")]
labels: Option<Vec<String>>,
#[arg(long, value_name = "FILE.xml")]
junit: Option<PathBuf>,
#[arg(long = "entry", value_name = "GLOB")]
entries: Vec<String>,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
require: MetaRequireArgs,
#[command(flatten)]
perf: perf_cmd::PerfArgs,
#[command(flatten)]
intent: IntentArgs,
}
#[derive(Subcommand)]
enum ExperimentOperation {
#[cfg(feature = "graphics")]
Ablate(perf_cmd::AblateArgs),
#[cfg(feature = "graphics")]
Temporal(temporal_cmd::TemporalArgs),
#[cfg(feature = "graphics")]
Sequence {
baseline_dir: PathBuf,
capture_dir: PathBuf,
#[arg(long, default_value = "*")]
pattern: String,
#[arg(long, default_value = "sequence-report")]
out: PathBuf,
#[arg(long)]
threshold: Option<f64>,
#[arg(long, value_enum)]
metric: Option<MetricArg>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
ppd: Option<f32>,
#[arg(long)]
fail_on_new: bool,
#[arg(long)]
allow_empty: bool,
#[arg(long, value_delimiter = ',')]
labels: Option<Vec<String>>,
#[arg(long)]
json: bool,
#[command(flatten)]
hdr: HdrArgs,
#[arg(long, value_name = "FILE.xml")]
junit: Option<PathBuf>,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
require: MetaRequireArgs,
},
#[cfg(feature = "graphics")]
Rank {
reference_dir: PathBuf,
#[arg(required = true, num_args = 1..)]
candidate_dirs: Vec<PathBuf>,
#[arg(long, value_delimiter = ',')]
labels: Option<Vec<String>>,
#[arg(long, value_enum, default_value = "mean")]
metric: MetricArg,
#[arg(long, default_value = "rank-report")]
out: PathBuf,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
threshold: Option<f64>,
#[arg(long)]
ppd: Option<f32>,
#[arg(long)]
fail_on_new: bool,
#[arg(long)]
allow_empty: bool,
#[arg(long)]
json: bool,
#[command(flatten)]
hdr: HdrArgs,
#[arg(long, value_name = "FILE.xml")]
junit: Option<PathBuf>,
#[command(flatten)]
meta: MetaArgs,
#[command(flatten)]
require: MetaRequireArgs,
},
#[cfg(feature = "graphics")]
Bisect(s6::BisectArgs),
#[cfg(feature = "prechecks")]
Safety(precheck::SafetyArgs),
#[cfg(feature = "prechecks")]
A11y(precheck::A11yArgs),
}
fn args_want_json(args: &[std::ffi::OsString]) -> bool {
let args: Vec<String> = args
.iter()
.skip(1)
.map(|a| a.to_string_lossy().into_owned())
.collect();
args.iter().enumerate().any(|(i, a)| {
a == "--json"
|| a.starts_with("--json=")
|| a == "--format=json"
|| (a == "--format" && args.get(i + 1).is_some_and(|v| v == "json"))
})
}
fn emit_json_error(err: &CliError) {
let text = serde_json::to_string_pretty(&err.value()).unwrap_or_default();
let _ = emit(&format!("{text}\n"));
}
fn main() -> ExitCode {
match std::thread::Builder::new()
.name("saccade-cli".into())
.stack_size(8 * 1024 * 1024)
.spawn(cli_main)
{
Ok(thread) => match thread.join() {
Ok(code) => code,
Err(panic) => std::panic::resume_unwind(panic),
},
Err(error) => {
let error = CliError::io(format!("starting CLI thread: {error}"));
let args: Vec<_> = std::env::args_os().collect();
if args_want_json(&args) {
emit_json_error(&error);
} else {
eprintln!("saccade: error: {error}\n fix: {}", error.hint);
}
ExitCode::from(2)
}
}
}
fn cli_main() -> ExitCode {
let mut args: Vec<std::ffi::OsString> = std::env::args_os().collect();
if let Some(message) = local_cmd::migration(&args) {
let err = CliError::new("interface_removed", message);
if args_want_json(&args) {
emit_json_error(&err);
} else {
eprintln!("saccade: {err}");
}
return ExitCode::from(2);
}
if let Some(replacement) = local_cmd::deprecated_alias(&mut args) {
eprintln!("saccade: deprecated command; use saccade {replacement}");
}
let cli = match Cli::try_parse_from(&args) {
Ok(cli) => cli,
Err(e) => {
if e.use_stderr()
&& let Some(feature) = requested_feature(&args)
{
let err = CliError::from(saccade_core::Error::FeatureUnavailable { feature });
if args_want_json(&args) {
emit_json_error(&err);
} else {
eprintln!("saccade: error: {err}\n fix: {}", err.hint);
}
return ExitCode::from(2);
}
if e.use_stderr() && args_want_json(&args) {
let text = e.to_string();
let message = text
.split("\n\n")
.next()
.unwrap_or("invalid arguments")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
emit_json_error(&CliError::usage(
message.trim_start_matches("error: ").to_string(),
));
return ExitCode::from(2);
}
if e.use_stderr() {
let _ = e.print();
return ExitCode::from(2);
}
e.exit()
}
};
let json_errors = args_want_json(&args);
match outdirs::warn(&cli.command, cli.allow_out_near_captures)
.and_then(|()| dispatch(cli.command, cli.record_absolute_paths))
{
Ok(code) => ExitCode::from(code),
Err(err) => {
if json_errors {
emit_json_error(&err);
} else {
eprintln!(
"saccade: error: {}\n fix: {}",
escape_multiline(&err.message),
escape_control(&err.hint)
);
}
ExitCode::from(2)
}
}
}
fn emit_run(
report: &Report,
out: &Path,
json: bool,
record_absolute_paths: bool,
) -> Result<(), CliError> {
let mut missing = std::collections::BTreeMap::<String, usize>::new();
let mut missing_pairs = 0usize;
let mut same_capture = 0usize;
for entry in &report.entries {
if entry
.warnings
.iter()
.any(|w| w.contains("provenance is absent"))
{
missing_pairs += 1;
}
for warning in &entry.warnings {
if warning.contains("provenance is absent") {
*missing
.entry(
warning
.split(" provenance is absent")
.next()
.unwrap_or(warning)
.to_owned(),
)
.or_default() += 1;
}
if warning == "same capture, not a repeat" {
same_capture += 1;
}
}
}
if !missing.is_empty() || same_capture > 0 {
eprintln!(
"saccade: warning: provenance is absent on {} image pairs ({}){}; inspect REPORT --validity-reasons --json for detail",
missing_pairs,
missing
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join(", "),
if same_capture > 0 {
format!("; same capture, not a repeat on {same_capture} pairs")
} else {
String::new()
}
);
}
if json {
let mut value = agent::result_value(
report,
&out.join(saccade_core::report::REPORT_FILE_NAME),
agent::DEFAULT_TOP_FAILING,
false,
);
if report.config.mode == Mode::Identity {
value["schema"] = serde_json::json!("saccade-result.v1");
}
let verification_file = out.join(saccade_core::intent::RESULT_FILE);
if verification_file.is_file() {
let finding: saccade_core::intent::Verification =
serde_json::from_value(local_cmd::read_value(&verification_file)?)?;
let matched = finding.unexpected.is_empty()
&& finding.missing.is_empty()
&& finding.unmeasurable.is_empty();
value["intent_verification"] = serde_json::json!({"status":if matched {"matched"} else {"mismatch"},"matched":finding.matched.len(),"unexpected":finding.unexpected.len(),"missing":finding.missing.len(),"unmeasurable":finding.unmeasurable.len(),"artifact":local_cmd::reference(&verification_file)?});
}
emit(&format!(
"{}\n",
serde_json::to_string(&local_cmd::bounded(value, 4096)?)?
))
} else {
emit(&text_table(report))?;
let verification_file = out.join(saccade_core::intent::RESULT_FILE);
if verification_file.is_file() {
let finding: saccade_core::intent::Verification =
serde_json::from_value(local_cmd::read_value(&verification_file)?)?;
emit(&format!(
"intent: {} matched, {} unexpected, {} missing, {} unmeasurable\n",
finding.matched.len(),
finding.unexpected.len(),
finding.missing.len(),
finding.unmeasurable.len()
))?;
}
emit(&run_footer(report, out, record_absolute_paths))?;
if report.config.mode == Mode::Identity
&& (!report.config.entries.is_empty() || !report.config.ignore.is_empty())
{
emit(&format!(
"scope: selected {:?}; excluded {:?}\n",
report.config.entries, report.config.ignore
))?;
}
Ok(())
}
}
fn dispatch(command: Command, record_absolute_paths: bool) -> Result<u8, CliError> {
match command {
Command::Prove {
operation: ProveOperation::Identity(args),
} => dispatch(
Command::Identity {
parent_dir: args.parent_dir,
candidate_dir: args.candidate_dir,
out: args.out,
allow_empty: args.allow_empty,
threshold: None,
metric: None,
config: args.config,
json: args.json,
ppd: args.ppd,
labels: args.labels,
entries: args.entries,
junit: args.junit,
meta: args.meta,
require: args.require,
perf: args.perf,
intent: args.intent,
},
record_absolute_paths,
),
#[cfg(feature = "graphics")]
Command::Prove {
operation: ProveOperation::Performance(args),
} => perf_cmd::ablate(*args, record_absolute_paths),
Command::Doctor { json } => doctor(json),
Command::Bisect(args) => git_bisect::run(args),
Command::Ingest(args) => ingest::run(args, record_absolute_paths),
Command::History(args) => history::run(args),
Command::Inspect(args) => local_cmd::inspect(args, record_absolute_paths),
Command::Review(args) => local_cmd::review(args, record_absolute_paths),
#[cfg(feature = "prechecks")]
Command::Experiment {
operation: ExperimentOperation::Safety(args),
} => precheck::safety(args),
#[cfg(feature = "prechecks")]
Command::Experiment {
operation: ExperimentOperation::A11y(args),
} => precheck::a11y(args),
Command::Init(args) => f1::init(args),
Command::Noise(args) => f1::noise(args, record_absolute_paths),
#[cfg(feature = "graphics")]
Command::Experiment {
operation: ExperimentOperation::Ablate(args),
} => perf_cmd::ablate(args, record_absolute_paths),
#[cfg(feature = "graphics")]
Command::Experiment {
operation: ExperimentOperation::Temporal(args),
} => temporal_cmd::run(args, record_absolute_paths),
Command::Demo(args) => f1::demo(args, record_absolute_paths),
#[cfg(feature = "graphics")]
Command::Experiment {
operation: ExperimentOperation::Bisect(args),
} => s6::bisect(args),
#[cfg(feature = "graphics")]
Command::Experiment {
operation:
ExperimentOperation::Sequence {
baseline_dir,
capture_dir,
pattern,
out,
threshold,
metric,
config,
ppd,
fail_on_new,
allow_empty,
labels,
json,
hdr,
junit,
meta,
require,
},
} => {
let mut cfg = load_config(config.as_deref())?;
cfg.record_absolute_paths = record_absolute_paths;
if let Some(t) = threshold {
cfg.default_threshold = t;
}
if let Some(m) = metric {
cfg.default_metric = m.into();
}
if let Some(p) = ppd {
cfg.pixels_per_degree = p;
}
if let Some(l) = labels {
cfg.labels = parse_labels(&l)?;
}
cfg.fail_on_new |= fail_on_new;
cfg.allow_empty |= allow_empty;
hdr.apply(&mut cfg.hdr)?;
meta.apply(&mut cfg.meta);
require.apply(&mut cfg.meta);
f1::guard_junit(junit.as_deref(), &[&baseline_dir, &capture_dir], &out)?;
let report = saccade_core::sequence::run_sequence(
&baseline_dir,
&capture_dir,
&out,
&pattern,
&cfg,
)?;
if let Some(path) = junit {
f1::junit_reports(&out, &path, false)?;
}
if json {
let value = local_cmd::analysis_result(&serde_json::to_value(&report)?, &out)?;
emit(&format!(
"{}
",
serde_json::to_string(&value)?
))?;
} else {
emit(&report.text())?;
}
Ok(u8::from(report.is_regression()))
}
#[cfg(feature = "graphics")]
Command::Experiment {
operation:
ExperimentOperation::Rank {
reference_dir,
candidate_dirs,
labels,
metric,
out,
config,
threshold,
ppd,
fail_on_new,
allow_empty,
json,
hdr,
junit,
meta,
require,
},
} => {
let mut cfg = load_config(config.as_deref())?;
cfg.record_absolute_paths = record_absolute_paths;
if let Some(t) = threshold {
cfg.default_threshold = t;
}
if let Some(p) = ppd {
cfg.pixels_per_degree = p;
}
cfg.fail_on_new |= fail_on_new;
cfg.allow_empty |= allow_empty;
hdr.apply(&mut cfg.hdr)?;
meta.apply(&mut cfg.meta);
require.apply(&mut cfg.meta);
f1::guard_junit(
junit.as_deref(),
&std::iter::once(reference_dir.as_path())
.chain(candidate_dirs.iter().map(PathBuf::as_path))
.collect::<Vec<_>>(),
&out,
)?;
let report = saccade_core::rank::run_rank(
&reference_dir,
&candidate_dirs,
labels.as_deref(),
metric.into(),
&out,
&cfg,
)?;
if let Some(path) = junit {
f1::junit_reports(&out, &path, true)?;
}
if json {
let value = local_cmd::analysis_result(&serde_json::to_value(&report)?, &out)?;
emit(&format!(
"{}
",
serde_json::to_string(&value)?
))?;
} else {
emit(&report.text())?;
}
Ok(
if report.common_images != report.reference_images
|| report
.overall
.iter()
.any(|c| c.totals.error > 0 || c.totals.missing > 0 || c.totals.new > 0)
{
2
} else {
0
},
)
}
Command::Compare {
baseline_dir,
capture_dir,
out,
threshold,
metric,
config,
fail_on_new,
allow_empty,
json,
ppd,
labels,
hdr,
junit,
entries,
meta,
require,
perf,
intent,
} => {
let mut cfg = load_config(config.as_deref())?;
cfg.record_absolute_paths = record_absolute_paths;
perf.apply(&mut cfg.perf)?;
cfg.entries = entries;
cfg.allow_empty |= allow_empty;
hdr.apply(&mut cfg.hdr)?;
meta.apply(&mut cfg.meta);
require.apply(&mut cfg.meta);
f1::guard_junit(junit.as_deref(), &[&baseline_dir, &capture_dir], &out)?;
if let Some(t) = threshold {
cfg.default_threshold = t;
}
if let Some(m) = metric {
cfg.default_metric = m.into();
}
if fail_on_new {
cfg.fail_on_new = true;
}
if let Some(p) = ppd {
cfg.pixels_per_degree = p;
}
if let Some(l) = labels {
cfg.labels = parse_labels(&l)?;
}
let visual = local_cmd::visual_intent(&intent)?;
let report = saccade_core::run::run(&baseline_dir, &capture_dir, &out, &cfg)?;
if let Some(path) = junit {
saccade_core::ergonomics::junit(&report, &path)?;
}
local_cmd::persist_case(
&report,
&out.join(saccade_core::report::REPORT_FILE_NAME),
&intent,
)?;
let intent_mismatch = local_cmd::verify_visual_intent(&report, &out, visual.as_ref())?;
emit_run(&report, &out, json, record_absolute_paths)?;
Ok(u8::from(report.is_regression() || intent_mismatch))
}
Command::Identity {
parent_dir,
candidate_dir,
out,
allow_empty,
threshold,
metric,
config,
json,
ppd,
labels,
junit,
entries,
meta,
require,
perf,
intent,
} => {
let mut cfg = load_config(config.as_deref())?;
cfg.record_absolute_paths = record_absolute_paths;
perf.apply(&mut cfg.perf)?;
cfg.entries = entries;
cfg.allow_empty |= allow_empty;
meta.apply(&mut cfg.meta);
require.apply(&mut cfg.meta);
f1::guard_junit(junit.as_deref(), &[&parent_dir, &candidate_dir], &out)?;
if threshold.is_some() || metric.is_some() {
return Err(CliError::usage(if threshold.is_some() {
"identity is exact; use compare --threshold"
} else {
"identity is exact; use compare --metric"
}));
}
cfg.mode = Mode::Identity;
cfg.labels = Labels {
baseline: "parent".into(),
capture: "candidate".into(),
};
cfg.default_threshold = threshold.unwrap_or(0.0);
cfg.default_metric = metric.map_or(Metric::Max, Into::into);
if let Some(p) = ppd {
cfg.pixels_per_degree = p;
}
if let Some(l) = labels {
cfg.labels = parse_labels(&l)?;
}
let visual = local_cmd::visual_intent(&intent)?;
let report = saccade_core::run::run(&parent_dir, &candidate_dir, &out, &cfg)?;
if let Some(path) = junit {
saccade_core::ergonomics::junit(&report, &path)?;
}
local_cmd::persist_case(
&report,
&out.join(saccade_core::report::REPORT_FILE_NAME),
&intent,
)?;
let intent_mismatch = local_cmd::verify_visual_intent(&report, &out, visual.as_ref())?;
emit_run(&report, &out, json, record_absolute_paths)?;
Ok(u8::from(report.is_regression() || intent_mismatch))
}
Command::Approve {
capture_dir,
baseline_dir,
mut names,
entries,
report,
all_failing,
decisions,
include_errors,
prune_missing,
json,
force,
dry_run,
out,
} => {
names.extend(entries);
let report_path = report.or_else(|| {
all_failing
.as_ref()
.filter(|p| p.as_os_str() != "__report__")
.cloned()
});
if all_failing
.as_ref()
.is_some_and(|p| p.as_os_str() == "__report__")
&& report_path.is_none()
{
return Err(CliError::usage(
"--all-failing needs --report REPORT_JSON or a legacy REPORT_JSON value",
));
}
if (include_errors || prune_missing) && report_path.is_none() {
return Err(CliError::usage(
"--include-errors and --prune-missing need --report REPORT_JSON or --all-failing REPORT_JSON",
));
}
for name in &names {
if !is_safe_name(name) {
return Err(CliError::new(
"unsafe_path",
format!("unsafe image name {name:?}"),
));
}
}
if force {
return Err(CliError::new(
"approve_mismatch",
"--force was removed; stale content must be reviewed again",
));
}
let report_path = report_path.ok_or_else(|| CliError::new("approve_mismatch", "approval requires --report and a canonical --decisions file; legacy finals must be reviewed again"))?;
let (capture, baseline) =
f1::approve_dirs(capture_dir, baseline_dir, Some(&report_path), None)?;
approval::run(
&report_path,
&capture,
&baseline,
decisions.as_deref(),
approval::Options {
names,
all_failing: all_failing.is_some(),
include_errors,
prune_missing,
dry_run,
out,
json,
absolute: record_absolute_paths,
},
)
}
#[cfg(feature = "workbench")]
Command::Serve {
mut roots,
registered_roots,
out_root,
follow_symlinks_within_roots,
symlink_targets,
fs_timeout_ms,
port,
cache_dir,
decisions_dir,
config,
ppd,
open,
hdr,
meta,
perf,
} => {
roots.extend(registered_roots);
if roots.is_empty() {
return Err(CliError::usage("serve requires ROOT or --root DIR"));
}
let loaded = load_config(config.as_deref())?;
if !loaded.symlink_targets.is_empty() {
return Err(CliError::usage(
"project config cannot authorize symlink targets; pass --symlink-target at startup",
));
}
let mut opts = saccade_core::serve::ServeOptions::new(roots.remove(0));
opts.extra_roots = roots;
opts.follow_symlinks_within_roots = follow_symlinks_within_roots;
opts.symlink_targets.extend(symlink_targets);
opts.fs_timeout_ms = fs_timeout_ms.unwrap_or(loaded.fs_timeout_ms);
opts.port = port;
if let Some(root) = out_root {
let all_roots = std::iter::once(opts.root.clone())
.chain(opts.extra_roots.clone())
.collect::<Vec<_>>();
let policy = saccade_core::root_policy::RootPolicy::new(
&all_roots,
Some(&root),
opts.follow_symlinks_within_roots,
&opts.symlink_targets,
)?;
opts.cache_dir = policy.write(Path::new("cache"))?;
opts.decisions_dir = policy.write(Path::new("decisions"))?;
}
if let Some(d) = cache_dir {
opts.cache_dir = d;
}
if let Some(d) = decisions_dir {
opts.decisions_dir = d;
}
opts.view.record_absolute_paths = record_absolute_paths;
opts.view.regions = loaded.regions;
opts.view.meta = loaded.meta;
opts.view.diagnostics = loaded.diagnostics;
opts.view.perf = loaded.perf;
perf.apply(&mut opts.view.perf)?;
hdr.apply(&mut opts.view.hdr)?;
meta.apply(&mut opts.view.meta);
if let Some(p) = ppd {
opts.view.pixels_per_degree = p;
}
let handle = saccade_core::serve::start(opts)?;
let url = format!("http://127.0.0.1:{}/", handle.port());
emit(&format!(
"saccade serve: {url}\n cache: {}\n decisions: {}\n (Ctrl-C to stop)\n",
escape_control(&handle.cache_dir().display().to_string()),
escape_control(&handle.decisions_dir().display().to_string())
))?;
if open {
open_browser(&url);
}
handle.wait();
Ok(0)
}
Command::View {
dirs,
unblind,
key,
labels,
reference,
blind,
seed,
key_out,
out,
ppd,
config,
json,
hdr,
entries,
meta,
perf,
} => {
if let Some(decisions) = unblind {
return local_cmd::unblind(
&decisions,
key.as_deref()
.ok_or_else(|| CliError::usage("--unblind requires --key"))?,
&out,
record_absolute_paths,
);
}
if dirs.len() == 1 {
return local_cmd::view_artifact(&dirs[0], &out, json);
}
if blind && key_out.is_none() {
return Err(CliError::usage(
"--blind requires --key-out outside the view bundle",
));
}
let loaded = load_config(config.as_deref())?;
let mut opts = ViewOptions {
entries,
record_absolute_paths,
regions: loaded.regions,
meta: loaded.meta,
diagnostics: loaded.diagnostics,
perf: loaded.perf,
labels,
reference,
blind,
seed,
key_out: key_out.clone(),
..ViewOptions::default()
};
perf.apply(&mut opts.perf)?;
hdr.apply(&mut opts.hdr)?;
meta.apply(&mut opts.meta);
if let Some(p) = ppd {
opts.pixels_per_degree = p;
}
let model = build_view(&dirs, &out, &opts)?;
let key = key_out.unwrap_or_else(|| saccade_core::view::private_key_path(&out));
if json {
let mut value = local_cmd::base_result("view");
value["artifact"] =
local_cmd::reference(&out.join(saccade_core::view::VIEW_MARKER_FILE))?;
value["counts"] =
serde_json::json!({"sets":model.sets.len(),"directories":model.labels.len()});
value["data"] = serde_json::json!({"blind":blind});
emit(&format!("{}\n", serde_json::to_string(&value)?))?;
return Ok(0);
}
emit(&format!(
"wrote {} ({} image sets, {} directories)\n",
escape_control(&out.join("index.html").display().to_string()),
model.sets.len(),
model.labels.len()
))?;
if blind {
emit(&format!(
"blind key (keep it away from the judge): {}\n",
escape_control(&key.display().to_string())
))?;
}
Ok(0)
}
#[cfg(feature = "mcp")]
Command::Mcp {
roots,
out_root,
follow_symlinks_within_roots,
symlink_targets,
#[cfg(feature = "ai")]
providers,
} => {
mcp::serve_stdio(
&roots,
out_root.as_deref(),
follow_symlinks_within_roots,
&symlink_targets,
#[cfg(feature = "ai")]
providers,
)?;
Ok(0)
}
}
}
fn doctor(json: bool) -> Result<u8, CliError> {
let mut features = saccade_core::COMPILED_FEATURES.to_vec();
if cfg!(feature = "mcp") {
features.push("mcp");
}
features.sort_unstable();
let mut capabilities = vec![
"compat-aliases",
"removed-flag-errors",
"version-skew-errors",
"provenance-warnings",
"repeat-detection",
"perf-v2",
"identity-json-v1",
"history-v1",
];
if cfg!(feature = "prechecks") {
capabilities.push("prechecks");
}
if cfg!(feature = "mcp") {
capabilities.push("mcp");
}
if cfg!(feature = "ai") {
capabilities.push("review");
}
capabilities.sort_unstable();
let git_commit = option_env!("SACCADE_GIT_COMMIT").filter(|value| !value.is_empty());
let git_commit_short =
option_env!("SACCADE_GIT_COMMIT_SHORT").filter(|value| !value.is_empty());
let git_dirty = match option_env!("SACCADE_GIT_DIRTY") {
Some("true") => Some(true),
Some("false") => Some(false),
_ => None,
};
let value = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"build": {
"git_commit": git_commit,
"git_commit_short": git_commit_short,
"git_dirty": git_dirty,
"profile": env!("SACCADE_BUILD_PROFILE"),
"rustc_version": env!("SACCADE_RUSTC_VERSION"),
},
"features": features,
"capabilities": capabilities,
"schemas": {
"report": ["saccade-report.v1"],
"result": ["saccade-result.v1", "saccade-result.v2"],
"evidence": ["saccade-evidence.v1"],
"image_noise": ["saccade-noise.v1"],
"performance": ["saccade-perf.v1", "saccade-perf.v2"]
}
});
if json {
emit(&format!("{}\n", serde_json::to_string(&value)?))?;
} else {
emit(&format!(
"saccade {}\nbuild: {}\nfeatures: {}\ncapabilities: {}\nschemas: {}\n",
env!("SACCADE_DISPLAY_VERSION"),
value["build"],
features.join(", "),
capabilities.join(", "),
value["schemas"]
))?;
}
Ok(0)
}
fn parse_labels(parts: &[String]) -> Result<Labels, String> {
match parts {
[a, b] if !a.trim().is_empty() && !b.trim().is_empty() => Ok(Labels {
baseline: a.trim().to_string(),
capture: b.trim().to_string(),
}),
_ => Err("--labels takes exactly two non-empty names, `A,B`".into()),
}
}
fn load_config(explicit: Option<&Path>) -> Result<RunConfig, CliError> {
let path = match explicit {
Some(p) => Some(p),
None => Some(Path::new("saccade.toml")).filter(|p| p.is_file()),
};
match path {
Some(p) => Ok(RunConfig::from_toml_file(p)?),
None => Ok(RunConfig::default()),
}
}
#[cfg(feature = "workbench")]
fn open_browser(url: &str) {
let (program, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("open", vec![url])
} else if cfg!(target_os = "windows") {
("cmd", vec!["/C", "start", "", url])
} else {
("xdg-open", vec![url])
};
let _ = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
fn read_report(path: &Path) -> Result<Report, CliError> {
let text = std::fs::read_to_string(path)
.map_err(|e| CliError::io(format!("reading report {}: {e}", path.display())))?;
let value: serde_json::Value = serde_json::from_str(&text)?;
if let Some(schema) = value.get("schema").and_then(|v| v.as_str())
&& schema
.strip_prefix("saccade-report.v")
.and_then(|v| v.parse::<u32>().ok())
.is_some_and(|v| v > 1)
{
return Err(CliError::new(
"version_skew",
format!(
"written by {schema}; installed saccade supports up to saccade-report.v1, upgrade"
),
));
}
serde_json::from_value(value).map_err(|e| {
if e.to_string().contains("unknown field") {
CliError::new("version_skew", format!("written by a newer producer; installed saccade supports up to saccade-report.v1, upgrade: {e}"))
} else {
CliError::io(format!("parsing report {}: {e}", path.display()))
}
})
}
fn emit(text: &str) -> Result<(), CliError> {
let mut out = std::io::stdout().lock();
match out.write_all(text.as_bytes()).and_then(|()| out.flush()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(CliError::io(format!("writing to stdout: {e}"))),
}
}
fn escape_control(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => out.push_str(&format!("\\x{:02x}", u32::from(c))),
c => out.push(c),
}
}
out
}
fn escape_multiline(s: &str) -> String {
s.lines()
.map(escape_control)
.collect::<Vec<_>>()
.join("\n ")
}
fn check_no_symlinks(baseline_dir: &Path, rel: &Path) -> Result<(), CliError> {
let mut cur = baseline_dir.to_path_buf();
for comp in rel.components() {
cur.push(comp);
match std::fs::symlink_metadata(&cur) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(CliError::new(
"unsafe_path",
format!("refusing to write through symlink {}", cur.display()),
));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
Err(e) => return Err(CliError::io(format!("inspecting {}: {e}", cur.display()))),
}
}
Ok(())
}
fn status_label(s: Status) -> &'static str {
match s {
Status::Pass => "pass",
Status::Fail => "FAIL",
Status::New => "new",
Status::Missing => "MISSING",
Status::Error => "ERROR",
}
}
fn metric_label(m: Metric) -> &'static str {
match m {
Metric::Mean => "mean",
Metric::P95 => "p95",
Metric::P99 => "p99",
Metric::Max => "max",
}
}
fn stdout_color() -> bool {
use std::io::IsTerminal;
std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty()) && std::io::stdout().is_terminal()
}
fn paint(text: &str, sgr: &str, on: bool) -> String {
if on {
format!("\x1b[{sgr}m{text}\x1b[0m")
} else {
text.to_owned()
}
}
fn status_sgr(s: Status) -> &'static str {
match s {
Status::Pass => "32",
Status::Fail => "1;31",
Status::New => "36",
Status::Missing | Status::Error => "33",
}
}
fn compare_headline(report: &Report) -> String {
let t = &report.totals;
let problems: Vec<String> = [
(t.fail, "fail"),
(t.error, "error"),
(t.missing, "missing"),
(t.new, "new"),
]
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, what)| format!("{n} {what}"))
.collect();
let images = if t.total == 1 { "image" } else { "images" };
if report.is_empty_run() {
format!("compare: ❌ nothing compared ({} {images} found)", t.total)
} else if report.is_regression() {
format!(
"compare: ❌ regression: {} of {} {images}",
problems.join(", "),
t.total
)
} else if problems.is_empty() {
format!(
"compare: ✅ no regression: {} of {} {images} passed",
t.pass, t.total
)
} else {
format!(
"compare: ✅ no regression: {} pass, {} not gated",
t.pass,
problems.join(", ")
)
}
}
fn run_footer(report: &Report, out: &Path, absolute: bool) -> String {
let page = saccade_core::paths::cwd(&out.join("index.html"), absolute);
let json =
saccade_core::paths::cwd(&out.join(saccade_core::report::REPORT_FILE_NAME), absolute);
let mut text = format!("report: {}\n", escape_control(&page));
let t = &report.totals;
let next = if report.is_empty_run() {
"check that both directories hold images with the same relative names".to_owned()
} else if !report.is_regression() {
return text;
} else if report.config.mode == Mode::Identity {
"open the report to see where the samples differ; use `saccade compare` when a perceptual tolerance is acceptable".to_owned()
} else if t.fail + t.error == 0 && t.missing > 0 && t.new == 0 {
"restore the missing captures, or remove their baselines after review".to_owned()
} else {
format!(
"open the report and check the numbered hotspots; if a change is intended, plan its approval with\n saccade approve --report {} --entry NAME --dry-run --out plan",
escape_control(&json)
)
};
text.push_str(&format!("next: {next}\n"));
text
}
fn text_table(report: &Report) -> String {
let color = stdout_color();
let mut rows: Vec<&saccade_core::Entry> = report.entries.iter().collect();
rows.sort_by_key(|e| (e.status == Status::Pass, e.name.clone()));
let compared: Vec<&saccade_core::Entry> = report
.entries
.iter()
.filter(|e| matches!(e.status, Status::Pass | Status::Fail))
.collect();
let shared: Vec<&String> = match compared.split_first() {
Some((first, rest)) if !rest.is_empty() => first
.warnings
.iter()
.filter(|w| {
!w.contains("provenance is absent") && rest.iter().all(|e| e.warnings.contains(w))
})
.collect(),
_ => Vec::new(),
};
let mut cells: Vec<[String; 5]> = vec![[
"STATUS".into(),
"NAME".into(),
"METRIC".into(),
"VALUE".into(),
"THRESHOLD".into(),
]];
let mut notes: Vec<Vec<String>> = vec![Vec::new()];
let mut statuses: Vec<Option<Status>> = vec![None];
for e in rows {
let mut lines = Vec::new();
if let Some(d) = &e.diagnostics {
if e.status == Status::Pass {
if !d.perf.is_empty() || !d.perf_not_comparable.is_empty() {
lines.push(escape_control(&d.verdict_line(e.bit_identical)));
}
} else {
lines.push(format!(
"{}: {}",
d.class.as_str(),
escape_control(&d.description)
));
lines.extend(
d.perf_summary()
.map(|p| format!("perf: {}", escape_control(&p))),
);
}
}
if e.status == Status::Fail {
lines.extend(saccade_core::hotspots::summary_line(&e.hotspots));
}
if let Some(note) = e.local_hotspot_note() {
lines.push(note);
}
if let Some(msg) = &e.error {
lines.push(format!("error: {}", escape_control(msg)));
}
let error_names_keys = e
.error
.as_deref()
.is_some_and(|m| m.contains("configuration differs"));
if !e.meta_diff.is_empty() && !error_names_keys {
let keys: Vec<&str> = e.meta_diff.iter().map(|d| d.key.as_str()).collect();
lines.push(format!(
"config differs: {}",
escape_control(&keys.join(", "))
));
}
for w in e
.warnings
.iter()
.filter(|w| !shared.contains(w) && !w.contains("provenance is absent"))
{
lines.push(format!("warning: {}", escape_control(w)));
}
notes.push(lines);
statuses.push(Some(e.status));
let (value, threshold) = match e.status {
Status::Pass | Status::Fail => (
e.value.map_or("-".into(), |v| format!("{v:.5}")),
format!("{}", e.threshold),
),
_ => ("-".into(), "-".into()),
};
cells.push([
status_label(e.status).into(),
escape_control(&e.name),
e.buffer.as_ref().map_or_else(
|| metric_label(e.metric_used).to_string(),
|b| format!("{} ({})", metric_label(b.metric), b.unit),
),
value,
threshold,
]);
}
let mut widths = [0usize; 5];
for row in &cells {
for (w, c) in widths.iter_mut().zip(row) {
*w = (*w).max(c.chars().count());
}
}
let mut out = String::new();
for (i, (row, note)) in cells.iter().zip(¬es).enumerate() {
let line: Vec<String> = row
.iter()
.zip(widths)
.map(|(c, w)| format!("{c:<w$}"))
.collect();
let line = line.join(" ");
let line = line.trim_end();
match (i, statuses.get(i)) {
(0, _) => out.push_str(&paint(line, "2", color)),
(_, Some(Some(s))) => {
let (word, rest) = line.split_at(widths[0].min(line.len()));
out.push_str(&paint(word, status_sgr(*s), color));
out.push_str(rest);
}
_ => out.push_str(line),
}
out.push('\n');
for note in note {
out.push_str(&format!(" ↳ {note}\n"));
}
}
if !shared.is_empty() {
out.push_str(&format!(
"\nShared by all {} compared pairs:\n",
compared.len()
));
for w in &shared {
out.push_str(&format!(" ↳ warning: {}\n", escape_control(w)));
}
}
let headline = saccade_core::render::identity_headline(report).unwrap_or_else(|| {
if report.config.mode == Mode::Regression {
compare_headline(report)
} else {
String::new()
}
});
if !headline.is_empty() {
let sgr = if report.is_regression() {
"1;31"
} else {
"1;32"
};
out.insert_str(
0,
&format!("{}\n\n", paint(&escape_control(&headline), sgr, color)),
);
}
if let Some(v) = &report.combined_verdict {
out.push_str(&format!("\n{}\n", escape_control(v)));
}
if let Some(d) = &report.perf_diff {
out.push_str(&format!("{}\n", escape_control(&d.summary(3))));
for w in &d.warnings {
out.push_str(&format!("warning: {}\n", escape_control(w)));
}
}
if report.perf_diff.is_some() || !report.perf_errors.is_empty() {
out.push_str("performance evidence present; run `saccade experiment ablate BASE CAPTURE --out DIR --json` for the ablation outcome.\n");
}
let t = &report.totals;
out.push_str(&format!(
"\n{} fail, {} error, {} missing, {} new, {} pass ({} total)\n",
t.fail, t.error, t.missing, t.new, t.pass, t.total
));
if report.is_empty_run() {
out.push_str(
"nothing compared: no image exists in both directories (--allow-empty accepts this)\n",
);
}
out
}
fn required_feature(operation: &str) -> Option<&'static str> {
match operation {
"ablate" | "bisect" | "sequence" | "temporal" | "rank" | "saccade_ablate"
| "saccade_bisect" | "saccade_sequence" | "saccade_rank" => Some("graphics"),
"saccade_review" => Some("ai"),
"calibrate"
| "selftest"
| "bench"
| "collect-labels"
| "saccade_judge_calibrate"
| "saccade_judge_bench" => Some("evaluation"),
"serve" | "ask" | "saccade_ask_human" | "saccade_inbox_get" => Some("workbench"),
"safety" | "a11y" | "saccade_safety" | "saccade_a11y" => Some("prechecks"),
"mcp" => Some("mcp"),
_ => None,
}
}
fn feature_enabled(feature: &str) -> bool {
if feature == "mcp" {
cfg!(feature = "mcp")
} else {
saccade_core::COMPILED_FEATURES.contains(&feature)
}
}
fn unavailable_feature(operation: &str) -> Option<&'static str> {
required_feature(operation).filter(|f| !feature_enabled(f))
}
fn requested_feature(args: &[std::ffi::OsString]) -> Option<&'static str> {
let operation = args
.iter()
.skip(1)
.find(|a| !a.to_string_lossy().starts_with('-'))?
.to_str()?;
unavailable_feature(operation).or_else(|| {
if operation == "experiment" {
args.iter()
.skip(2)
.find_map(|a| a.to_str().and_then(unavailable_feature))
} else if operation == "init" && !feature_enabled("ai") {
args.windows(2)
.any(|w| {
w[0] == "--template"
&& matches!(w[1].to_str(), Some("ci" | "nightly" | "lookdev"))
})
.then_some("ai")
} else {
None
}
})
}
pub(crate) fn capabilities(json: bool) -> Result<u8, CliError> {
use clap::CommandFactory;
fn operations(command: &clap::Command, prefix: &str, out: &mut Vec<String>) {
for child in command.get_subcommands() {
let name = if prefix.is_empty() {
child.get_name().to_owned()
} else {
format!("{prefix} {}", child.get_name())
};
out.push(name.clone());
operations(child, &name, out);
}
}
let mut names = Vec::new();
operations(&Cli::command(), "", &mut names);
let mut features = saccade_core::COMPILED_FEATURES.to_vec();
if cfg!(feature = "mcp") {
features.push("mcp");
}
features.sort_unstable();
let value = serde_json::json!({
"features": features,
"operations": names,
"contract_versions": ["saccade-report.v1", "saccade-result.v2", "saccade-evidence.v1", "saccade-noise.v1"],
});
if json {
let mut result = local_cmd::base_result("capabilities");
result["data"] = value.clone();
emit(&format!("{}\n", serde_json::to_string_pretty(&result)?))?;
} else {
emit(&format!(
"features: {}\noperations: {}\ncontracts: {}\n",
features.join(", "),
names.join(", "),
value["contract_versions"]
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>()
.join(", ")
))?;
}
Ok(0)
}