use crate::exit_codes::{ReasonCode, RunOutcome};
use std::path::{Path, PathBuf};
fn reason_code_from_run_error_kind(kind: assay_core::errors::RunErrorKind) -> Option<ReasonCode> {
use assay_core::errors::RunErrorKind;
match kind {
RunErrorKind::TraceNotFound => Some(ReasonCode::ETraceNotFound),
RunErrorKind::MissingConfig => Some(ReasonCode::EMissingConfig),
RunErrorKind::ConfigParse => Some(ReasonCode::ECfgParse),
RunErrorKind::InvalidArgs => Some(ReasonCode::EInvalidArgs),
RunErrorKind::ProviderRateLimit => Some(ReasonCode::ERateLimit),
RunErrorKind::ProviderTimeout => Some(ReasonCode::ETimeout),
RunErrorKind::ProviderServer => Some(ReasonCode::EProvider5xx),
RunErrorKind::Network => Some(ReasonCode::ENetworkError),
RunErrorKind::JudgeUnavailable => Some(ReasonCode::EJudgeUnavailable),
RunErrorKind::Other => None,
}
}
pub(crate) fn reason_code_from_run_error(err: &assay_core::errors::RunError) -> Option<ReasonCode> {
reason_code_from_run_error_kind(err.kind.clone())
}
fn run_error_kind_from_details(
details: &serde_json::Value,
) -> Option<assay_core::errors::RunErrorKind> {
use assay_core::errors::RunErrorKind;
let kind = details.get("run_error_kind")?.as_str()?;
match kind {
"trace_not_found" => Some(RunErrorKind::TraceNotFound),
"missing_config" => Some(RunErrorKind::MissingConfig),
"config_parse" => Some(RunErrorKind::ConfigParse),
"invalid_args" => Some(RunErrorKind::InvalidArgs),
"provider_rate_limit" => Some(RunErrorKind::ProviderRateLimit),
"provider_timeout" => Some(RunErrorKind::ProviderTimeout),
"provider_server" => Some(RunErrorKind::ProviderServer),
"network" => Some(RunErrorKind::Network),
"judge_unavailable" => Some(RunErrorKind::JudgeUnavailable),
"other" => Some(RunErrorKind::Other),
_ => None,
}
}
fn reason_code_from_result_row(row: &assay_core::model::TestResultRow) -> Option<ReasonCode> {
if let Some(kind) = run_error_kind_from_details(&row.details) {
return reason_code_from_run_error_kind(kind);
}
reason_code_from_error_message(&row.message)
}
pub(crate) fn reason_code_from_error_message(message: &str) -> Option<ReasonCode> {
use assay_core::errors::RunError;
let classified = RunError::legacy_classify_message(message.to_string());
reason_code_from_run_error(&classified)
}
pub(crate) fn decide_run_outcome(
results: &[assay_core::model::TestResultRow],
strict: bool,
version: crate::exit_codes::ExitCodeVersion,
) -> crate::exit_codes::RunOutcome {
use assay_core::model::TestStatus;
let make_outcome = |reason: ReasonCode, msg: Option<String>, context: Option<&str>| {
let mut o = RunOutcome::from_reason(reason, msg, context);
o.exit_code = reason.exit_code_for(version);
o
};
for r in results {
if let Some(reason) = reason_code_from_result_row(r) {
if matches!(
reason,
ReasonCode::ETraceNotFound
| ReasonCode::EMissingConfig
| ReasonCode::ECfgParse
| ReasonCode::EInvalidArgs
) {
return make_outcome(reason, Some(r.message.clone()), None);
}
}
}
let infra_errors: Vec<&assay_core::model::TestResultRow> = results
.iter()
.filter(|r| matches!(r.status, TestStatus::Error))
.collect();
if !infra_errors.is_empty() {
let reason = pick_infra_reason(&infra_errors);
return make_outcome(
reason,
Some("Infrastructure failures detected".into()),
None,
);
}
let abstain_count = results
.iter()
.filter(|r| has_judge_verdict_abstain(&r.details))
.count();
if abstain_count > 0 {
let mut o = RunOutcome::judge_uncertain(abstain_count);
o.exit_code = ReasonCode::EJudgeUncertain.exit_code_for(version);
return o;
}
let fails = results
.iter()
.filter(|r| matches!(r.status, TestStatus::Fail))
.count();
if fails > 0 {
let mut o = RunOutcome::test_failure(fails);
o.exit_code = ReasonCode::ETestFailed.exit_code_for(version);
return o;
}
if strict {
let violations = results
.iter()
.filter(|r| {
matches!(
r.status,
TestStatus::Warn | TestStatus::Flaky | TestStatus::Unstable
)
})
.count();
if violations > 0 {
return make_outcome(
ReasonCode::EPolicyViolation,
Some(format!("Strict mode: {} policy violations", violations)),
None,
);
}
}
let mut o = RunOutcome::success();
o.exit_code = ReasonCode::Success.exit_code_for(version);
o
}
pub(crate) fn has_judge_verdict_abstain(details: &serde_json::Value) -> bool {
let Some(metrics) = details.get("metrics").and_then(|m| m.as_object()) else {
return false;
};
for (_name, metric_val) in metrics {
if let Some(inner) = metric_val.get("details").and_then(|d| d.get("verdict")) {
if inner.as_str() == Some("Abstain") {
return true;
}
}
}
false
}
fn pick_infra_reason(
errors: &[&assay_core::model::TestResultRow],
) -> crate::exit_codes::ReasonCode {
for r in errors {
if let Some(reason) = reason_code_from_result_row(r) {
if matches!(
reason,
ReasonCode::ERateLimit
| ReasonCode::ETimeout
| ReasonCode::EProvider5xx
| ReasonCode::ENetworkError
| ReasonCode::EJudgeUnavailable
) {
return reason;
}
}
}
ReasonCode::EJudgeUnavailable
}
pub(crate) fn summary_from_outcome(
outcome: &crate::exit_codes::RunOutcome,
verify_enabled: bool,
) -> assay_core::report::summary::Summary {
let assay_version = env!("CARGO_PKG_VERSION");
if outcome.exit_code == 0 {
assay_core::report::summary::Summary::success(assay_version, verify_enabled)
} else {
assay_core::report::summary::Summary::failure(
outcome.exit_code,
&outcome.reason_code,
outcome.message.as_deref().unwrap_or(""),
outcome.next_step.as_deref().unwrap_or(""),
assay_version,
verify_enabled,
)
}
}
pub(crate) fn write_extended_run_json(
artifacts: &assay_core::report::RunArtifacts,
outcome: &crate::exit_codes::RunOutcome,
path: &PathBuf,
sarif_omitted: Option<u64>,
) -> anyhow::Result<()> {
let mut v = serde_json::to_value(artifacts)?;
if let Some(obj) = v.as_object_mut() {
obj.insert(
"exit_code".to_string(),
serde_json::json!(outcome.exit_code),
);
obj.insert(
"reason_code".to_string(),
serde_json::json!(outcome.reason_code),
);
obj.insert(
"reason_code_version".to_string(),
serde_json::json!(assay_core::report::summary::REASON_CODE_VERSION),
);
obj.insert(
"seed_version".to_string(),
serde_json::json!(assay_core::report::summary::SEED_VERSION),
);
let order_seed_json = match artifacts.order_seed {
Some(n) => serde_json::Value::String(n.to_string()),
None => serde_json::Value::Null,
};
obj.insert("order_seed".to_string(), order_seed_json);
obj.insert("judge_seed".to_string(), serde_json::Value::Null);
if let Some(metrics) =
assay_core::report::summary::judge_metrics_from_results(&artifacts.results)
{
obj.insert("judge_metrics".to_string(), serde_json::to_value(metrics)?);
}
if let Some(n) = sarif_omitted {
if n > 0 {
obj.insert("sarif".to_string(), serde_json::json!({ "omitted": n }));
}
}
obj.insert("resolution".to_string(), serde_json::to_value(outcome)?);
if !outcome.warnings.is_empty() {
obj.insert("warnings".into(), serde_json::json!(outcome.warnings));
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(&v)?)?;
Ok(())
}
pub(crate) fn write_run_json_minimal(
outcome: &crate::exit_codes::RunOutcome,
path: &PathBuf,
) -> anyhow::Result<()> {
let v = serde_json::json!({
"exit_code": outcome.exit_code,
"reason_code": outcome.reason_code,
"reason_code_version": assay_core::report::summary::REASON_CODE_VERSION,
"seed_version": assay_core::report::summary::SEED_VERSION,
"order_seed": null,
"judge_seed": null,
"resolution": outcome
});
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(&v)?)?;
Ok(())
}
pub(crate) fn export_baseline(
path: &PathBuf,
config_path: &Path,
cfg: &assay_core::model::EvalConfig,
results: &[assay_core::model::TestResultRow],
) -> anyhow::Result<()> {
let mut entries = Vec::new();
for r in results {
if let Some(metrics) = r.details.get("metrics").and_then(|v| v.as_object()) {
for (metric_name, m_val) in metrics {
if let Some(score) = m_val.get("score").and_then(|s| s.as_f64()) {
entries.push(assay_core::baseline::BaselineEntry {
test_id: r.test_id.clone(),
metric: metric_name.clone(),
score,
meta: None,
});
}
}
}
}
let b = assay_core::baseline::Baseline {
schema_version: 1,
suite: cfg.suite.clone(),
assay_version: env!("CARGO_PKG_VERSION").to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
config_fingerprint: assay_core::baseline::compute_config_fingerprint(config_path),
git_info: None,
entries,
};
b.save(path)?;
eprintln!("exported baseline to {}", path.display());
Ok(())
}
#[cfg(test)]
mod run_outcome_tests {
use super::{
decide_run_outcome, has_judge_verdict_abstain, reason_code_from_error_message,
reason_code_from_run_error,
};
use crate::exit_codes::{ExitCodeVersion, ReasonCode, EXIT_INFRA_ERROR};
use assay_core::errors::RunError;
use assay_core::model::{TestResultRow, TestStatus};
#[test]
fn test_infra_beats_abstain_precedence() {
let results = vec![
TestResultRow {
test_id: "infra".into(),
status: TestStatus::Error,
score: None,
cached: false,
message: "Request timeout".into(),
details: serde_json::json!({}),
duration_ms: None,
fingerprint: None,
skip_reason: None,
attempts: None,
error_policy_applied: None,
},
TestResultRow {
test_id: "abstain".into(),
status: TestStatus::Pass,
score: Some(0.5),
cached: false,
message: String::new(),
details: serde_json::json!({
"metrics": {
"faithfulness": {
"details": { "verdict": "Abstain", "score": 0.5 }
}
}
}),
duration_ms: None,
fingerprint: None,
skip_reason: None,
attempts: None,
error_policy_applied: None,
},
];
let outcome = decide_run_outcome(&results, false, ExitCodeVersion::V2);
assert_eq!(
outcome.exit_code, EXIT_INFRA_ERROR,
"infra must beat abstain: expected exit 3"
);
assert!(
outcome.reason_code == ReasonCode::ETimeout.as_str()
|| outcome.reason_code == ReasonCode::EJudgeUnavailable.as_str(),
"reason should be infra (E_TIMEOUT or E_JUDGE_UNAVAILABLE), got {}",
outcome.reason_code
);
}
#[test]
fn test_has_judge_verdict_abstain_detects_abstain() {
let details = serde_json::json!({
"metrics": {
"faithfulness": {
"score": 0.5,
"passed": false,
"unstable": true,
"details": { "verdict": "Abstain", "score": 0.5 }
}
}
});
assert!(has_judge_verdict_abstain(&details));
}
#[test]
fn test_has_judge_verdict_abstain_ignores_pass() {
let details = serde_json::json!({
"metrics": {
"faithfulness": {
"score": 1.0,
"passed": true,
"unstable": false,
"details": { "verdict": "Pass", "score": 1.0 }
}
}
});
assert!(!has_judge_verdict_abstain(&details));
}
#[test]
fn test_has_judge_verdict_abstain_no_metrics() {
let details = serde_json::json!({});
assert!(!has_judge_verdict_abstain(&details));
}
#[test]
fn test_reason_code_from_error_message_maps_config_family() {
assert_eq!(
reason_code_from_error_message("trace not found: traces/missing.jsonl"),
Some(ReasonCode::ETraceNotFound)
);
assert_eq!(
reason_code_from_error_message("Config file not found: eval.yaml"),
Some(ReasonCode::EMissingConfig)
);
assert_eq!(
reason_code_from_error_message("config error: unknown field `foo`"),
Some(ReasonCode::ECfgParse)
);
}
#[test]
fn test_reason_code_from_error_message_maps_infra_family() {
assert_eq!(
reason_code_from_error_message("provider returned 429 rate limit"),
Some(ReasonCode::ERateLimit)
);
assert_eq!(
reason_code_from_error_message("request timeout while calling judge"),
Some(ReasonCode::ETimeout)
);
assert_eq!(
reason_code_from_error_message("provider error: 503"),
Some(ReasonCode::EProvider5xx)
);
assert_eq!(
reason_code_from_error_message("network connection reset by peer"),
Some(ReasonCode::ENetworkError)
);
}
#[test]
fn test_reason_code_from_run_error_uses_typed_kind() {
let typed = RunError::missing_config("eval.yaml", "missing");
assert_eq!(
reason_code_from_run_error(&typed),
Some(ReasonCode::EMissingConfig)
);
assert!(!typed.legacy_classified);
}
#[test]
fn test_decide_outcome_uses_typed_details_before_legacy_message_fallback() {
let row = TestResultRow {
test_id: "typed".into(),
status: TestStatus::Error,
score: None,
cached: false,
message: "untyped error text".into(),
details: serde_json::json!({
"run_error_kind": "invalid_args"
}),
duration_ms: None,
fingerprint: None,
skip_reason: None,
attempts: None,
error_policy_applied: None,
};
let outcome = decide_run_outcome(&[row], true, ExitCodeVersion::V2);
assert_eq!(outcome.reason_code, ReasonCode::EInvalidArgs.as_str());
assert_eq!(
outcome.exit_code,
ReasonCode::EInvalidArgs.exit_code_for(ExitCodeVersion::V2)
);
}
}