use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum ExitCodeVersion {
V1,
#[default]
V2,
}
pub const EXIT_SUCCESS: i32 = 0;
pub const EXIT_TEST_FAILURE: i32 = 1;
pub const EXIT_CONFIG_ERROR: i32 = 2;
pub const EXIT_INFRA_ERROR: i32 = 3;
pub const EXIT_WOULD_BLOCK: i32 = 4;
pub const SUCCESS: i32 = EXIT_SUCCESS;
pub const COMMAND_FAILED: i32 = EXIT_TEST_FAILURE;
pub const INTERNAL_ERROR: i32 = EXIT_CONFIG_ERROR;
pub const POLICY_UNENFORCEABLE: i32 = EXIT_CONFIG_ERROR;
pub const VIOLATION_AUDIT: i32 = EXIT_INFRA_ERROR;
pub const WOULD_BLOCK: i32 = EXIT_WOULD_BLOCK;
pub const OK: i32 = EXIT_SUCCESS;
pub const TEST_FAILED: i32 = EXIT_TEST_FAILURE;
pub const CONFIG_ERROR: i32 = EXIT_CONFIG_ERROR;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ReasonCode {
Success,
ECfgParse,
ETraceNotFound,
EMissingConfig,
EBaselineInvalid,
EPolicyParse,
EReplayMissingDependency,
EInvalidArgs,
EJudgeUnavailable,
ERateLimit,
EProvider5xx,
ETimeout,
ENetworkError,
ETestFailed,
EJudgeUncertain,
EPolicyViolation,
ESequenceViolation,
EArgSchema,
}
impl ReasonCode {
pub fn exit_code_for(&self, version: ExitCodeVersion) -> i32 {
match version {
ExitCodeVersion::V1 => self.exit_code_v1(),
ExitCodeVersion::V2 => self.exit_code_v2(),
}
}
pub fn exit_code(&self) -> i32 {
self.exit_code_v2()
}
fn exit_code_v2(&self) -> i32 {
match self {
ReasonCode::Success => EXIT_SUCCESS,
ReasonCode::ECfgParse
| ReasonCode::ETraceNotFound
| ReasonCode::EMissingConfig
| ReasonCode::EBaselineInvalid
| ReasonCode::EPolicyParse
| ReasonCode::EReplayMissingDependency
| ReasonCode::EInvalidArgs => EXIT_CONFIG_ERROR,
ReasonCode::EJudgeUnavailable
| ReasonCode::ERateLimit
| ReasonCode::EProvider5xx
| ReasonCode::ETimeout
| ReasonCode::ENetworkError => EXIT_INFRA_ERROR,
ReasonCode::ETestFailed
| ReasonCode::EJudgeUncertain
| ReasonCode::EPolicyViolation
| ReasonCode::ESequenceViolation
| ReasonCode::EArgSchema => EXIT_TEST_FAILURE,
}
}
fn exit_code_v1(&self) -> i32 {
match self {
ReasonCode::Success => EXIT_SUCCESS,
ReasonCode::EReplayMissingDependency => EXIT_CONFIG_ERROR,
ReasonCode::ETraceNotFound => EXIT_INFRA_ERROR,
_ => self.exit_code_v2(),
}
}
pub fn as_str(&self) -> &'static str {
match self {
ReasonCode::Success => "",
ReasonCode::ECfgParse => "E_CFG_PARSE",
ReasonCode::ETraceNotFound => "E_TRACE_NOT_FOUND",
ReasonCode::EMissingConfig => "E_MISSING_CONFIG",
ReasonCode::EBaselineInvalid => "E_BASELINE_INVALID",
ReasonCode::EPolicyParse => "E_POLICY_PARSE",
ReasonCode::EReplayMissingDependency => "E_REPLAY_MISSING_DEPENDENCY",
ReasonCode::EInvalidArgs => "E_INVALID_ARGS",
ReasonCode::EJudgeUnavailable => "E_JUDGE_UNAVAILABLE",
ReasonCode::ERateLimit => "E_RATE_LIMIT",
ReasonCode::EProvider5xx => "E_PROVIDER_5XX",
ReasonCode::ETimeout => "E_TIMEOUT",
ReasonCode::ENetworkError => "E_NETWORK_ERROR",
ReasonCode::ETestFailed => "E_TEST_FAILED",
ReasonCode::EJudgeUncertain => "E_JUDGE_UNCERTAIN",
ReasonCode::EPolicyViolation => "E_POLICY_VIOLATION",
ReasonCode::ESequenceViolation => "E_SEQUENCE_VIOLATION",
ReasonCode::EArgSchema => "E_ARG_SCHEMA",
}
}
pub fn next_step(&self, context: Option<&str>) -> String {
match self {
ReasonCode::Success => String::new(),
ReasonCode::ECfgParse => {
format!(
"Run: assay doctor --config {}",
context.unwrap_or("<config.yaml>")
)
}
ReasonCode::ETraceNotFound => {
format!(
"Check trace file exists: {}",
context.unwrap_or("<trace.jsonl>")
)
}
ReasonCode::EMissingConfig => "Run: assay init to create a config file".to_string(),
ReasonCode::EBaselineInvalid => {
"Run: assay baseline record to create a new baseline".to_string()
}
ReasonCode::EPolicyParse => {
format!(
"Run: assay policy validate {}",
context.unwrap_or("<policy.yaml>")
)
}
ReasonCode::EReplayMissingDependency => {
"Replay bundle missing required offline dependency; rerun with --live or create a complete bundle".to_string()
}
ReasonCode::EInvalidArgs => "Run: assay --help for usage".to_string(),
ReasonCode::EJudgeUnavailable => {
"Check judge/LLM provider status and API key".to_string()
}
ReasonCode::ERateLimit => {
"Retry after rate limit window or reduce concurrency".to_string()
}
ReasonCode::EProvider5xx => {
"Provider error; retry or check provider status page".to_string()
}
ReasonCode::ETimeout => "Increase timeout or check network connectivity".to_string(),
ReasonCode::ENetworkError => {
"Check network connectivity and firewall rules".to_string()
}
ReasonCode::ETestFailed => "Run: assay explain <test-id> for details".to_string(),
ReasonCode::EJudgeUncertain => {
"Review borderline result or adjust judge threshold; run: assay explain <test-id>"
.to_string()
}
ReasonCode::EPolicyViolation => {
"Run: assay explain <test-id> or review policy rules".to_string()
}
ReasonCode::ESequenceViolation => {
"Run: assay explain <test-id> to see sequence mismatch".to_string()
}
ReasonCode::EArgSchema => {
"Run: assay explain <test-id> to see schema violation".to_string()
}
}
}
}
impl std::fmt::Display for ReasonCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunOutcome {
pub exit_code: i32,
pub reason_code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_step: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
impl RunOutcome {
pub fn success() -> Self {
Self {
exit_code: EXIT_SUCCESS,
reason_code: String::new(),
message: None,
next_step: None,
warnings: Vec::new(),
}
}
pub fn from_reason(reason: ReasonCode, message: Option<String>, context: Option<&str>) -> Self {
let next_step = if reason != ReasonCode::Success {
Some(reason.next_step(context))
} else {
None
};
Self {
exit_code: reason.exit_code(),
reason_code: reason.as_str().to_string(),
message,
next_step,
warnings: Vec::new(),
}
}
pub fn test_failure(failed_count: usize) -> Self {
Self {
exit_code: EXIT_TEST_FAILURE,
reason_code: ReasonCode::ETestFailed.as_str().to_string(),
message: Some(format!("{} test(s) failed", failed_count)),
next_step: Some("Run: assay explain <test-id> for details".to_string()),
warnings: Vec::new(),
}
}
pub fn judge_uncertain(abstain_count: usize) -> Self {
Self {
exit_code: EXIT_TEST_FAILURE,
reason_code: ReasonCode::EJudgeUncertain.as_str().to_string(),
message: Some(format!(
"Judge uncertain (abstain) for {} test(s); cannot decide pass/fail",
abstain_count
)),
next_step: Some(
"Review borderline result or adjust judge threshold; run: assay explain <test-id>"
.to_string(),
),
warnings: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exit_code_constants() {
assert_eq!(EXIT_SUCCESS, 0);
assert_eq!(EXIT_TEST_FAILURE, 1);
assert_eq!(EXIT_CONFIG_ERROR, 2);
assert_eq!(EXIT_INFRA_ERROR, 3);
assert_eq!(EXIT_WOULD_BLOCK, 4);
}
#[test]
fn test_reason_code_exit_mapping() {
assert_eq!(ReasonCode::Success.exit_code(), EXIT_SUCCESS);
assert_eq!(ReasonCode::ECfgParse.exit_code(), EXIT_CONFIG_ERROR);
assert_eq!(ReasonCode::ETraceNotFound.exit_code(), EXIT_CONFIG_ERROR);
assert_eq!(ReasonCode::EMissingConfig.exit_code(), EXIT_CONFIG_ERROR);
assert_eq!(ReasonCode::EBaselineInvalid.exit_code(), EXIT_CONFIG_ERROR);
assert_eq!(ReasonCode::EPolicyParse.exit_code(), EXIT_CONFIG_ERROR);
assert_eq!(
ReasonCode::EReplayMissingDependency.exit_code(),
EXIT_CONFIG_ERROR
);
assert_eq!(ReasonCode::EInvalidArgs.exit_code(), EXIT_CONFIG_ERROR);
assert_eq!(ReasonCode::EJudgeUnavailable.exit_code(), EXIT_INFRA_ERROR);
assert_eq!(ReasonCode::ERateLimit.exit_code(), EXIT_INFRA_ERROR);
assert_eq!(ReasonCode::EProvider5xx.exit_code(), EXIT_INFRA_ERROR);
assert_eq!(ReasonCode::ETimeout.exit_code(), EXIT_INFRA_ERROR);
assert_eq!(ReasonCode::ENetworkError.exit_code(), EXIT_INFRA_ERROR);
assert_eq!(ReasonCode::ETestFailed.exit_code(), EXIT_TEST_FAILURE);
assert_eq!(ReasonCode::EJudgeUncertain.exit_code(), EXIT_TEST_FAILURE);
assert_eq!(ReasonCode::EPolicyViolation.exit_code(), EXIT_TEST_FAILURE);
assert_eq!(
ReasonCode::ESequenceViolation.exit_code(),
EXIT_TEST_FAILURE
);
assert_eq!(ReasonCode::EArgSchema.exit_code(), EXIT_TEST_FAILURE);
}
#[test]
fn test_replay_missing_dependency_profile_stability() {
assert_eq!(
ReasonCode::EReplayMissingDependency.exit_code_for(ExitCodeVersion::V1),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EReplayMissingDependency.exit_code_for(ExitCodeVersion::V2),
EXIT_CONFIG_ERROR
);
}
#[test]
fn test_reason_code_as_str() {
assert_eq!(ReasonCode::Success.as_str(), "");
assert_eq!(ReasonCode::ECfgParse.as_str(), "E_CFG_PARSE");
assert_eq!(ReasonCode::ETraceNotFound.as_str(), "E_TRACE_NOT_FOUND");
assert_eq!(ReasonCode::EMissingConfig.as_str(), "E_MISSING_CONFIG");
assert_eq!(ReasonCode::EBaselineInvalid.as_str(), "E_BASELINE_INVALID");
assert_eq!(ReasonCode::EPolicyParse.as_str(), "E_POLICY_PARSE");
assert_eq!(
ReasonCode::EReplayMissingDependency.as_str(),
"E_REPLAY_MISSING_DEPENDENCY"
);
assert_eq!(ReasonCode::EInvalidArgs.as_str(), "E_INVALID_ARGS");
assert_eq!(
ReasonCode::EJudgeUnavailable.as_str(),
"E_JUDGE_UNAVAILABLE"
);
assert_eq!(ReasonCode::ERateLimit.as_str(), "E_RATE_LIMIT");
assert_eq!(ReasonCode::EProvider5xx.as_str(), "E_PROVIDER_5XX");
assert_eq!(ReasonCode::ETimeout.as_str(), "E_TIMEOUT");
assert_eq!(ReasonCode::ENetworkError.as_str(), "E_NETWORK_ERROR");
assert_eq!(ReasonCode::ETestFailed.as_str(), "E_TEST_FAILED");
assert_eq!(ReasonCode::EJudgeUncertain.as_str(), "E_JUDGE_UNCERTAIN");
assert_eq!(ReasonCode::EPolicyViolation.as_str(), "E_POLICY_VIOLATION");
assert_eq!(
ReasonCode::ESequenceViolation.as_str(),
"E_SEQUENCE_VIOLATION"
);
assert_eq!(ReasonCode::EArgSchema.as_str(), "E_ARG_SCHEMA");
}
#[test]
fn test_reason_code_next_step() {
assert!(ReasonCode::Success.next_step(None).is_empty());
assert!(ReasonCode::ECfgParse
.next_step(Some("test.yaml"))
.contains("test.yaml"));
assert!(ReasonCode::ETraceNotFound
.next_step(Some("traces/ci.jsonl"))
.contains("traces/ci.jsonl"));
assert!(ReasonCode::EMissingConfig
.next_step(None)
.contains("assay init"));
assert!(ReasonCode::EBaselineInvalid
.next_step(None)
.contains("baseline"));
assert!(ReasonCode::EPolicyParse
.next_step(None)
.contains("policy validate"));
assert!(ReasonCode::EReplayMissingDependency
.next_step(None)
.contains("--live"));
assert!(ReasonCode::EInvalidArgs.next_step(None).contains("--help"));
assert!(ReasonCode::EJudgeUnavailable
.next_step(None)
.contains("provider"));
assert!(ReasonCode::ERateLimit
.next_step(None)
.contains("rate limit"));
assert!(ReasonCode::EProvider5xx.next_step(None).contains("retry"));
assert!(ReasonCode::ETimeout.next_step(None).contains("timeout"));
assert!(ReasonCode::ENetworkError
.next_step(None)
.contains("network"));
assert!(ReasonCode::ETestFailed
.next_step(None)
.contains("assay explain"));
assert!(ReasonCode::EJudgeUncertain
.next_step(None)
.contains("borderline"));
assert!(ReasonCode::EPolicyViolation
.next_step(None)
.contains("explain"));
assert!(ReasonCode::ESequenceViolation
.next_step(None)
.contains("explain"));
assert!(ReasonCode::EArgSchema.next_step(None).contains("explain"));
}
#[test]
fn test_reason_code_display() {
assert_eq!(
format!("{}", ReasonCode::ETraceNotFound),
"E_TRACE_NOT_FOUND"
);
assert_eq!(format!("{}", ReasonCode::Success), "");
}
#[test]
fn test_run_outcome_success() {
let outcome = RunOutcome::success();
assert_eq!(outcome.exit_code, EXIT_SUCCESS);
assert_eq!(outcome.reason_code, "");
assert!(outcome.message.is_none());
assert!(outcome.next_step.is_none());
}
#[test]
fn test_run_outcome_from_reason() {
let outcome = RunOutcome::from_reason(
ReasonCode::ETraceNotFound,
Some("File not found: test.jsonl".to_string()),
Some("test.jsonl"),
);
assert_eq!(outcome.exit_code, EXIT_CONFIG_ERROR);
assert_eq!(outcome.reason_code, "E_TRACE_NOT_FOUND");
assert!(outcome.message.as_ref().unwrap().contains("test.jsonl"));
assert!(outcome.next_step.as_ref().unwrap().contains("test.jsonl"));
}
#[test]
fn test_run_outcome_test_failure() {
let outcome = RunOutcome::test_failure(3);
assert_eq!(outcome.exit_code, EXIT_TEST_FAILURE);
assert_eq!(outcome.reason_code, "E_TEST_FAILED");
assert!(outcome.message.as_ref().unwrap().contains("3 test(s)"));
assert!(outcome.next_step.as_ref().unwrap().contains("explain"));
}
#[test]
fn test_run_outcome_serialization() {
let outcome = RunOutcome::test_failure(2);
let json = serde_json::to_string(&outcome).unwrap();
assert!(json.contains("\"exit_code\":1"));
assert!(json.contains("\"reason_code\":\"E_TEST_FAILED\""));
assert!(json.contains("2 test(s) failed"));
}
#[test]
fn test_run_outcome_judge_uncertain() {
let outcome = RunOutcome::judge_uncertain(1);
assert_eq!(outcome.exit_code, EXIT_TEST_FAILURE);
assert_eq!(outcome.reason_code, "E_JUDGE_UNCERTAIN");
assert!(outcome.message.as_ref().unwrap().contains("uncertain"));
assert!(outcome.next_step.as_ref().unwrap().contains("borderline"));
}
}