use crate::errors::{Error, ModelError, ValidationError};
use crate::models::ModelSession;
use crate::validation::fixtures::load_fixture_set;
use crate::validation::report::{ModelValidationSummary, ValidationStatus};
use crate::validation::validate_session_with_fixture_set;
use crate::viz::manifest::{ArtifactKind, OutputArtifactManifest};
use crate::viz::OutputFormat;
use clap::Args;
use serde_json::json;
use std::path::PathBuf;
#[derive(Args, Debug)]
pub struct ValidateArgs {
#[arg(long = "model", required = true, value_delimiter = ',')]
pub models: Vec<String>,
#[arg(long)]
pub fixture_set: Option<String>,
#[arg(short, long, default_value = "terminal")]
pub format: OutputFormat,
#[arg(short, long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub refresh_goldens: bool,
}
pub fn run(args: ValidateArgs) -> Result<(), Error> {
if args.models.is_empty() {
return Err(
ValidationError::Usage("At least one --model value is required.".to_string()).into(),
);
}
if matches!(args.format, OutputFormat::Png) {
return Err(ValidationError::Usage(
"validate only supports terminal, json, or html output.".to_string(),
)
.into());
}
let fixture_set = load_fixture_set(args.fixture_set.as_deref())?;
let mut summaries = Vec::with_capacity(args.models.len());
for model in &args.models {
let mut session = ModelSession::load(model).map_err(|err| match err {
ModelError::NotFound(_) => {
ValidationError::Usage(format!("Unknown model identifier: {model}"))
}
other => ValidationError::FailedValidation {
model: model.clone(),
reason: other.to_string(),
},
})?;
let validation_result =
validate_session_with_fixture_set(&mut session, &fixture_set, args.refresh_goldens);
match validation_result {
Ok(summary) => summaries.push(summary),
Err(err @ ValidationError::Usage(_))
| Err(err @ ValidationError::MissingFixtures(_)) => return Err(err.into()),
Err(ValidationError::FailedValidation { reason, .. })
| Err(ValidationError::ContractMismatch { reason, .. }) => {
summaries.push(
ModelValidationSummary::failed(
model,
&fixture_set.manifest.evidence_timestamp,
reason,
)
.with_backend(session.backend()),
);
}
Err(other) => {
summaries.push(
ModelValidationSummary::failed(
model,
&fixture_set.manifest.evidence_timestamp,
other.to_string(),
)
.with_backend(session.backend()),
);
}
}
}
render_validate_output(&args, &summaries)?;
if summaries
.iter()
.all(|summary| matches!(summary.status, ValidationStatus::Validated))
{
Ok(())
} else {
Err(ValidationError::FailedValidation {
model: args.models.join(","),
reason: "One or more models did not reach validated status.".to_string(),
}
.into())
}
}
fn render_validate_output(
args: &ValidateArgs,
summaries: &[ModelValidationSummary],
) -> Result<(), Error> {
match args.format {
OutputFormat::Terminal => crate::viz::terminal::print_validation_summaries(summaries),
OutputFormat::Json => {
if let Some(outdir) = &args.output {
std::fs::create_dir_all(outdir)?;
let path = outdir.join("validation.json");
crate::viz::json::write_validation_report(summaries, &path)?;
OutputArtifactManifest::new("validate", OutputFormat::Json)
.with_primary_artifact("validation.json")
.with_context(validate_manifest_context(args))
.with_summary(validate_manifest_summary(args, summaries))
.add_artifact("validation.json", ArtifactKind::Json, "Validation report")
.with_validation(summaries)
.write_to_dir(outdir)?;
println!(
"Validation report written to {}/validation.json",
outdir.display()
);
} else {
crate::viz::json::print_validation_summaries(summaries)?;
}
}
OutputFormat::Html => {
let outdir = args
.output
.clone()
.unwrap_or_else(|| PathBuf::from("validation_output"));
std::fs::create_dir_all(&outdir)?;
let manifest = OutputArtifactManifest::new("validate", OutputFormat::Html)
.with_primary_artifact("validation.html")
.with_context(validate_manifest_context(args))
.with_summary(validate_manifest_summary(args, summaries))
.add_artifact("validation.html", ArtifactKind::Html, "Validation report")
.add_artifact(
"validation.json",
ArtifactKind::Json,
"Validation report data",
)
.with_validation(summaries);
crate::viz::json::write_validation_report(summaries, &outdir.join("validation.json"))?;
let bundle = manifest.finalize_for_bundle_display(&outdir)?;
crate::viz::html::write_validation_report_with_bundle(
summaries,
Some(&bundle),
&outdir.join("validation.html"),
)?;
manifest.write_to_dir(&outdir)?;
println!(
"Validation report written to {}/validation.html",
outdir.display()
);
}
OutputFormat::Png => unreachable!("validated earlier"),
}
Ok(())
}
fn validate_manifest_context(args: &ValidateArgs) -> serde_json::Value {
json!({
"models": args.models,
"fixture_set": args.fixture_set,
"refresh_goldens": args.refresh_goldens,
})
}
fn validate_manifest_summary(
args: &ValidateArgs,
summaries: &[ModelValidationSummary],
) -> serde_json::Value {
let failed_models = summaries
.iter()
.filter(|summary| !matches!(summary.status, ValidationStatus::Validated))
.map(|summary| summary.model.clone())
.collect::<Vec<_>>();
let checked_signals = summaries
.iter()
.map(|summary| summary.parity.checked_signals)
.sum::<usize>();
let drifted_signals = summaries
.iter()
.map(|summary| summary.parity.drifted_signals)
.sum::<usize>();
json!({
"model_count": summaries.len(),
"refresh_goldens": args.refresh_goldens,
"evidence_timestamp": summaries.first().map(|summary| summary.evidence_timestamp.clone()),
"failed_models": failed_models,
"checked_signals": checked_signals,
"drifted_signals": drifted_signals,
})
}