use std::path::{Path, PathBuf};
use saccade_core::runs::{
NoAssets, Pairing, RunInput, RunsModel, RunsOptions, overview, unique_labels, write_static,
};
use crate::agent::CliError;
pub const DEFAULT_OUT: &str = "runs";
pub struct RunsRequest {
pub ref_dir: PathBuf,
pub run_dirs: Vec<PathBuf>,
pub labels: Option<Vec<String>>,
pub json: bool,
pub out: Option<PathBuf>,
pub by_position: bool,
pub opts: RunsOptions,
pub record_absolute_paths: bool,
}
fn inputs(req: &RunsRequest) -> Result<(RunInput, Vec<RunInput>), CliError> {
let dirs: Vec<PathBuf> = std::iter::once(&req.ref_dir)
.chain(&req.run_dirs)
.cloned()
.collect();
let labels = match &req.labels {
Some(l) if l.len() != dirs.len() => {
return Err(CliError::usage(format!(
"--labels takes one label per directory ({}), got {}",
dirs.len(),
l.len()
)));
}
Some(l) => l.clone(),
None => {
let names: Vec<PathBuf> = dirs
.iter()
.map(|d| saccade_core::explain::absolute(d))
.collect();
unique_labels(&names)
}
};
let mut all: Vec<RunInput> = dirs
.into_iter()
.zip(labels)
.map(|(dir, label)| RunInput {
display: if !req.json || req.out.is_some() {
saccade_core::paths::record(
&dir,
req.out.as_deref().unwrap_or(Path::new(DEFAULT_OUT)),
req.record_absolute_paths,
)
} else {
saccade_core::paths::cwd(&dir, req.record_absolute_paths)
},
dir,
label,
pairing: Pairing::Name,
})
.collect();
let reference = all.remove(0);
if req.by_position {
for r in &mut all {
r.pairing = Pairing::Position;
}
}
Ok((reference, all))
}
fn cache_dir() -> PathBuf {
saccade_core::local::default_cache_dir().join("runs")
}
fn text(model: &RunsModel, page: Option<&Path>) -> String {
let mut out = format!(
"reference {} ({} images)\n",
model.reference.label,
model.reference.images.len()
);
for r in &model.runs {
out.push_str(&format!(" {}: {}\n", r.label, r.summary));
if r.perf_diff.is_some() || !r.perf_errors.is_empty() {
out.push_str(&format!(" {} ยท {}\n", r.flag, r.combined_verdict));
if let Some(d) = &r.perf_diff {
out.push_str(&format!(" {}\n", d.summary(3)));
}
for e in &r.perf_errors {
out.push_str(&format!(" error: {e}\n"));
}
}
if r.no_visible_effect {
out.push_str(if r.perf_diff.is_some() {
" No visible effect: every image is bit-identical.\n"
} else {
" No visible effect: this run changed nothing.\n"
});
}
if r.mismatch && r.pairing == "name" {
out.push_str(&format!(
" {} of {} file names match; try --pair-by-position\n",
r.name_matches,
model.reference.images.len()
));
}
}
if let Some(p) = page {
out.push_str(&format!("wrote {}\n", p.join("index.html").display()));
}
out
}
#[cfg(feature = "mcp")]
pub fn lean_value(model: &RunsModel) -> Result<serde_json::Value, CliError> {
fn strip(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(m) => {
m.retain(|_, x| !x.is_null());
m.values_mut().for_each(strip);
}
serde_json::Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut value = serde_json::to_value(model)?;
strip(&mut value);
Ok(value)
}
#[cfg(feature = "mcp")]
pub fn summary_text(model: &RunsModel) -> String {
text(model, None)
}
pub fn run(req: &RunsRequest) -> Result<u8, CliError> {
let (reference, runs) = inputs(req)?;
let cache = cache_dir();
let write_page = !req.json || req.out.is_some();
let model = if write_page {
let out = req
.out
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_OUT));
write_static(&reference, &runs, &req.opts, &cache, &out)?
} else {
overview(&reference, &runs, &req.opts, Some(&cache), &NoAssets)?
};
if req.json {
crate::emit(&format!("{}\n", serde_json::to_string_pretty(&model)?))?;
} else {
let out = req
.out
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_OUT));
crate::emit(&text(&model, Some(&out)))?;
}
Ok(0)
}