use assay_core::errors::ConfigLoadError;
use serde::{Deserialize, Serialize};
use std::io::ErrorKind;
#[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,
EReplayLimitExceeded,
#[doc = include_str!("exit_codes/evidence_integrity_boundary.md")]
EEvidenceIntegrity,
#[doc = include_str!("exit_codes/evidence_contract_boundary.md")]
EEvidenceContract,
#[doc = include_str!("exit_codes/evidence_limit_boundary.md")]
EEvidenceLimitExceeded,
#[doc = include_str!("exit_codes/evidence_path_boundary.md")]
EEvidencePathRejected,
EEvidenceProfileInvalid,
EEvidenceUnreadable,
EInvalidArgs,
EJudgeUnavailable,
ERateLimit,
EProvider5xx,
ETimeout,
ENetworkError,
ETestFailed,
EJudgeUncertain,
EPolicyViolation,
ESequenceViolation,
EArgSchema,
}
pub(crate) fn format_recovery_argv(args: &[&str]) -> String {
format!("Run argv: {}", serde_json::json!(args))
}
pub(crate) fn reason_for_unloadable_explicit_config(err: &ConfigLoadError) -> ReasonCode {
match err.io_kind() {
Some(ErrorKind::NotFound) => ReasonCode::EMissingConfig,
Some(_) | None => ReasonCode::ECfgParse,
}
}
pub(crate) fn fused_option(flag: &str, value: &str) -> String {
format!("{flag}={value}")
}
pub(crate) fn positional_operand<'a>(prefix: &'a [&'a str], operand: &'a str) -> Vec<&'a str> {
let mut argv = Vec::with_capacity(prefix.len() + 2);
argv.extend_from_slice(prefix);
argv.push("--");
argv.push(operand);
argv
}
pub(crate) fn validate_recovery_argv(config: &str, replay_trace: Option<&str>) -> Vec<String> {
let mut argv = vec![
"assay".to_string(),
"validate".to_string(),
fused_option("--config", config),
];
if let Some(trace) = replay_trace {
argv.push(fused_option("--trace-file", trace));
}
argv.push("--format".to_string());
argv.push("json".to_string());
argv
}
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::EReplayLimitExceeded
| ReasonCode::EReplayMissingDependency
| ReasonCode::EEvidenceIntegrity
| ReasonCode::EEvidenceContract
| ReasonCode::EEvidenceLimitExceeded
| ReasonCode::EEvidencePathRejected
| ReasonCode::EEvidenceProfileInvalid
| ReasonCode::EEvidenceUnreadable
| 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 {
#[expect(
clippy::wildcard_enum_match_arm,
reason = "the V1 compat mapping names the codes whose exit differed under V1 and lets the rest fall to their V2 code; a new code is V2-only until someone decides it had a V1 meaning"
)]
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::EReplayLimitExceeded => "E_REPLAY_LIMIT_EXCEEDED",
ReasonCode::EEvidenceIntegrity => "E_EVIDENCE_INTEGRITY",
ReasonCode::EEvidenceContract => "E_EVIDENCE_CONTRACT",
ReasonCode::EEvidenceLimitExceeded => "E_EVIDENCE_LIMIT_EXCEEDED",
ReasonCode::EEvidencePathRejected => "E_EVIDENCE_PATH_REJECTED",
ReasonCode::EEvidenceProfileInvalid => "E_EVIDENCE_PROFILE_INVALID",
ReasonCode::EEvidenceUnreadable => "E_EVIDENCE_UNREADABLE",
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 => {
let config = fused_option("--config", context.unwrap_or("<config.yaml>"));
format_recovery_argv(&["assay", "doctor", &config, "--format", "json"])
}
ReasonCode::ETraceNotFound => {
format!(
"Check trace file exists: {}",
context.unwrap_or("<trace.jsonl>")
)
}
ReasonCode::EReplayLimitExceeded => {
"Raise the replay ingest ceiling that was named, or supply a smaller bundle"
.to_string()
}
ReasonCode::EEvidenceIntegrity => {
"Obtain an undamaged bundle from its producer; the content this bundle carries \
does not match what it records"
.to_string()
}
ReasonCode::EEvidenceContract => {
"Obtain or reissue evidence that conforms to the declared bundle contract; \
this bundle was readable and does not satisfy that contract"
.to_string()
}
ReasonCode::EEvidenceLimitExceeded => {
"Verification stopped at a configured ceiling and reached no verdict; obtain a \
smaller bundle from its producer, or raise the ceiling deliberately and repeat \
the inspection"
.to_string()
}
ReasonCode::EEvidencePathRejected => {
"An archive member path was refused as unsafe to extract; obtain a bundle whose \
member paths stay inside the extraction root from its producer"
.to_string()
}
ReasonCode::EEvidenceProfileInvalid => {
"Obtain or reissue evidence whose records satisfy the named evidence profile; \
per-violation details are in findings"
.to_string()
}
ReasonCode::EEvidenceUnreadable => {
let argv = positional_operand(
&["assay", "evidence", "show", "--format", "json"],
context.unwrap_or("<bundle>"),
);
format_recovery_argv(&argv)
}
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 => {
let input = fused_option("--input", context.unwrap_or("<policy.yaml>"));
format_recovery_argv(&[
"assay",
"policy",
"validate",
&input,
"--format",
"json",
])
}
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 unloadable_explicit_config_class_follows_read_io_kind() {
let cases = [
(Some(ErrorKind::NotFound), ReasonCode::EMissingConfig),
(Some(ErrorKind::PermissionDenied), ReasonCode::ECfgParse),
(Some(ErrorKind::IsADirectory), ReasonCode::ECfgParse),
(Some(ErrorKind::Other), ReasonCode::ECfgParse),
(None, ReasonCode::ECfgParse),
];
for (kind, expected) in cases {
let err = match kind {
Some(kind) => ConfigLoadError::from_read("failed to read config", kind),
None => {
ConfigLoadError::new("failed to parse YAML: mapping values are not allowed")
}
};
assert_eq!(
reason_for_unloadable_explicit_config(&err),
expected,
"kind {kind:?} must not invent a more specific class"
);
}
}
#[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::EEvidenceIntegrity.exit_code(),
EXIT_CONFIG_ERROR
);
assert_eq!(ReasonCode::EEvidenceContract.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 evidence_integrity_holds_one_exit_class_across_profiles() {
assert_eq!(
ReasonCode::EEvidenceIntegrity.exit_code_for(ExitCodeVersion::V1),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceIntegrity.exit_code_for(ExitCodeVersion::V2),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceUnreadable.exit_code_for(ExitCodeVersion::V1),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceUnreadable.exit_code_for(ExitCodeVersion::V2),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceContract.exit_code_for(ExitCodeVersion::V1),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceContract.exit_code_for(ExitCodeVersion::V2),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceLimitExceeded.exit_code_for(ExitCodeVersion::V1),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidenceLimitExceeded.exit_code_for(ExitCodeVersion::V2),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidencePathRejected.exit_code_for(ExitCodeVersion::V1),
EXIT_CONFIG_ERROR
);
assert_eq!(
ReasonCode::EEvidencePathRejected.exit_code_for(ExitCodeVersion::V2),
EXIT_CONFIG_ERROR
);
}
fn assert_remediation_is_prose_only(code: ReasonCode) {
let next_step = code.next_step(None);
let name = code.as_str();
assert!(
!next_step.is_empty(),
"{name}: the remediation must not be empty"
);
assert!(
!next_step.starts_with("Run:") && !next_step.starts_with("Run argv:"),
"{name}: remediation must stay prose, not an executable claim: {next_step}"
);
assert!(
!next_step.to_ascii_lowercase().contains("assay"),
"{name}: remediation names this tool, so it publishes something the reader can run \
as a remedy for a condition running it cannot repair: {next_step}"
);
assert!(
!next_step.contains('/') && !next_step.contains('\\'),
"{name}: remediation must not interpolate a path: {next_step}"
);
assert_eq!(
next_step,
code.next_step(Some("bundle; rm -rf /.tar.gz")),
"{name}: remediation changed with caller-supplied context"
);
}
#[test]
fn prose_only_rule_rejects_an_embedded_invocation() {
let embedded = "Try assay evidence show --format json to inspect the records";
assert!(
embedded.to_ascii_lowercase().contains("assay"),
"the control string no longer carries an invocation, so it controls nothing"
);
let prose = "Obtain or reissue evidence from its producer";
assert!(
!prose.to_ascii_lowercase().contains("assay")
&& !prose.contains('/')
&& !prose.starts_with("Run:"),
"the accepted control is no longer plain prose"
);
}
#[test]
fn evidence_limit_remediation_promises_no_command() {
assert_remediation_is_prose_only(ReasonCode::EEvidenceLimitExceeded);
}
#[test]
fn evidence_path_remediation_promises_no_command() {
assert_remediation_is_prose_only(ReasonCode::EEvidencePathRejected);
}
#[test]
fn evidence_contract_remediation_promises_no_command() {
assert_remediation_is_prose_only(ReasonCode::EEvidenceContract);
}
#[test]
fn evidence_profile_invalid_remediation_promises_no_command() {
assert_remediation_is_prose_only(ReasonCode::EEvidenceProfileInvalid);
}
#[test]
fn evidence_integrity_remediation_promises_no_command() {
assert_remediation_is_prose_only(ReasonCode::EEvidenceIntegrity);
}
#[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::EEvidenceIntegrity.as_str(),
"E_EVIDENCE_INTEGRITY"
);
assert_eq!(
ReasonCode::EEvidenceContract.as_str(),
"E_EVIDENCE_CONTRACT"
);
assert_eq!(
ReasonCode::EEvidenceProfileInvalid.as_str(),
"E_EVIDENCE_PROFILE_INVALID"
);
assert_eq!(
ReasonCode::EEvidenceUnreadable.as_str(),
"E_EVIDENCE_UNREADABLE"
);
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());
let config_path = "cfg file;$(touch should-not-exist).yaml";
let config_next_step = ReasonCode::ECfgParse.next_step(Some(config_path));
assert_eq!(
config_next_step,
r#"Run argv: ["assay","doctor","--config=cfg file;$(touch should-not-exist).yaml","--format","json"]"#,
"the shared recovery formatter must preserve the published compact representation"
);
let config_argv: Vec<String> = serde_json::from_str(
config_next_step
.strip_prefix("Run argv: ")
.expect("config recovery must publish JSON argv"),
)
.expect("config recovery argv must parse");
assert_eq!(
config_argv,
vec![
"assay".to_string(),
"doctor".to_string(),
format!("--config={config_path}"),
"--format".to_string(),
"json".to_string()
]
);
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"));
let policy_path = "pol icy;$(echo x).yaml";
let next_step = ReasonCode::EPolicyParse.next_step(Some(policy_path));
let argv: Vec<String> = serde_json::from_str(
next_step
.strip_prefix("Run argv: ")
.expect("policy recovery must publish JSON argv"),
)
.expect("policy recovery argv must parse");
assert_eq!(
argv,
vec![
"assay".to_string(),
"policy".to_string(),
"validate".to_string(),
format!("--input={policy_path}"),
"--format".to_string(),
"json".to_string()
]
);
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 dynamic_recovery_argv_round_trips_json_significant_paths() {
let path = "cfg \"quoted\"\\nested\nline\ttab\u{0007}.yaml";
let cases = [
(
ReasonCode::ECfgParse,
vec![
"assay".into(),
"doctor".into(),
format!("--config={path}"),
"--format".into(),
"json".into(),
],
),
(
ReasonCode::EPolicyParse,
vec![
"assay".into(),
"policy".into(),
"validate".into(),
format!("--input={path}"),
"--format".into(),
"json".into(),
],
),
(
ReasonCode::EEvidenceUnreadable,
vec![
"assay".into(),
"evidence".into(),
"show".into(),
"--format".into(),
"json".into(),
"--".into(),
path.into(),
],
),
];
for (reason, expected) in cases {
let next_step = reason.next_step(Some(path));
let encoded = next_step
.strip_prefix("Run argv: ")
.expect("dynamic recovery must publish JSON argv");
let argv: Vec<String> =
serde_json::from_str(encoded).expect("recovery argv must remain valid JSON");
assert_eq!(argv, expected, "{reason:?} must preserve one path argument");
}
}
#[test]
fn path_bearing_recovery_always_fuses_the_flag_and_includes_json_format() {
for path in ["-weird.yaml", "--config", "-h", "plain.yaml"] {
let config = ReasonCode::ECfgParse.next_step(Some(path));
let policy = ReasonCode::EPolicyParse.next_step(Some(path));
let config_argv: Vec<String> = serde_json::from_str(
config
.strip_prefix("Run argv: ")
.expect("config recovery must publish JSON argv"),
)
.expect("config recovery argv must parse");
let policy_argv: Vec<String> = serde_json::from_str(
policy
.strip_prefix("Run argv: ")
.expect("policy recovery must publish JSON argv"),
)
.expect("policy recovery argv must parse");
assert_eq!(
config_argv,
vec![
"assay".to_string(),
"doctor".to_string(),
format!("--config={path}"),
"--format".to_string(),
"json".to_string()
]
);
assert_eq!(
policy_argv,
vec![
"assay".to_string(),
"policy".to_string(),
"validate".to_string(),
format!("--input={path}"),
"--format".to_string(),
"json".to_string()
]
);
assert!(
!config.contains('\u{0007}') && !policy.contains('\u{0007}'),
"recovery text must not carry raw BEL"
);
}
}
fn publishes_recovery_argv(reason: ReasonCode) -> bool {
match reason {
ReasonCode::ECfgParse | ReasonCode::EPolicyParse | ReasonCode::EEvidenceUnreadable => {
true
}
ReasonCode::Success
| ReasonCode::ETraceNotFound
| ReasonCode::EMissingConfig
| ReasonCode::EBaselineInvalid
| ReasonCode::EReplayMissingDependency
| ReasonCode::EReplayLimitExceeded
| ReasonCode::EEvidenceIntegrity
| ReasonCode::EEvidenceContract
| ReasonCode::EEvidenceLimitExceeded
| ReasonCode::EEvidencePathRejected
| ReasonCode::EEvidenceProfileInvalid
| ReasonCode::EInvalidArgs
| ReasonCode::EJudgeUnavailable
| ReasonCode::ERateLimit
| ReasonCode::EProvider5xx
| ReasonCode::ETimeout
| ReasonCode::ENetworkError
| ReasonCode::ETestFailed
| ReasonCode::EJudgeUncertain
| ReasonCode::EPolicyViolation
| ReasonCode::ESequenceViolation
| ReasonCode::EArgSchema => false,
}
}
#[test]
fn every_reason_that_publishes_argv_is_a_known_executable_recovery() {
let mut published = Vec::new();
for reason in [
ReasonCode::Success,
ReasonCode::ECfgParse,
ReasonCode::ETraceNotFound,
ReasonCode::EMissingConfig,
ReasonCode::EBaselineInvalid,
ReasonCode::EPolicyParse,
ReasonCode::EReplayMissingDependency,
ReasonCode::EReplayLimitExceeded,
ReasonCode::EEvidenceIntegrity,
ReasonCode::EEvidenceContract,
ReasonCode::EEvidenceLimitExceeded,
ReasonCode::EEvidencePathRejected,
ReasonCode::EEvidenceProfileInvalid,
ReasonCode::EEvidenceUnreadable,
ReasonCode::EInvalidArgs,
ReasonCode::EJudgeUnavailable,
ReasonCode::ERateLimit,
ReasonCode::EProvider5xx,
ReasonCode::ETimeout,
ReasonCode::ENetworkError,
ReasonCode::ETestFailed,
ReasonCode::EJudgeUncertain,
ReasonCode::EPolicyViolation,
ReasonCode::ESequenceViolation,
ReasonCode::EArgSchema,
] {
let classified = publishes_recovery_argv(reason);
let emitted = reason.next_step(Some("x")).starts_with("Run argv: ");
assert_eq!(
emitted, classified,
"{reason:?}: classification and published next_step must agree"
);
if emitted {
published.push(reason);
}
}
assert_eq!(
published,
[
ReasonCode::ECfgParse,
ReasonCode::EPolicyParse,
ReasonCode::EEvidenceUnreadable,
],
"a new argv publisher must join the cross-publisher harness; dropping one is the skipped-variant mutation"
);
}
#[test]
fn positional_operand_owns_separator_before_the_value() {
let prefix = ["assay", "evidence", "show", "--format", "json"];
let operand = "-bundle.tar.gz";
let argv = positional_operand(&prefix, operand);
assert_eq!(
argv,
vec![
"assay",
"evidence",
"show",
"--format",
"json",
"--",
"-bundle.tar.gz"
]
);
let mut swapped = prefix.to_vec();
swapped.push(operand);
swapped.push("--");
assert_ne!(
argv, swapped,
"operand `--` would make this assertion tautological"
);
}
#[test]
fn validate_recovery_argv_fuses_config_and_omits_generator_events() {
assert_eq!(
validate_recovery_argv("-weird.yaml", None),
vec![
"assay",
"validate",
"--config=-weird.yaml",
"--format",
"json"
]
);
assert_eq!(
validate_recovery_argv("-weird.yaml", Some("traces/hello.jsonl")),
vec![
"assay",
"validate",
"--config=-weird.yaml",
"--trace-file=traces/hello.jsonl",
"--format",
"json"
]
);
}
#[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"));
}
}