use std::error::Error;
use std::ffi::OsString;
use std::fmt::{Display, Formatter};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;
use runx_contracts::{JsonObject, JsonValue, Receipt};
use runx_receipts::{
canonical_receipt_body_digest, canonical_receipt_digest, canonical_receipt_json,
};
use runx_runtime::harness::{HarnessFixtureCase, list_cases};
use runx_runtime::{
HarnessReplayOutput, InvocationStatus, RuntimeOptions, SkillAdapter, SkillInvocation,
SkillOutput, run_harness_fixture_with_adapter,
};
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
let _ignored = writeln!(io::stderr().lock(), "runx: {error}");
ExitCode::from(1)
}
}
}
fn run() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse(std::env::args_os().skip(1))?;
let mut failed = false;
let mut summaries = Vec::new();
for fixture in list_cases() {
let summary = process_fixture(&cli, fixture)?;
failed |= summary.status == SummaryStatus::Failed;
summaries.push(summary);
}
if cli.summary_json {
let report = SummaryReport {
schema: "runx.harness_fixture_replay_summary.v1",
cases: summaries,
};
write_stdout_line(&serde_json::to_string_pretty(&report)?)?;
}
if failed {
Err(Box::new(MessageError(
"one or more harness fixture oracles are stale".to_owned(),
)))
} else {
Ok(())
}
}
fn process_fixture(
cli: &Cli,
fixture: &HarnessFixtureCase,
) -> Result<FixtureSummary, Box<dyn Error>> {
let started = Instant::now();
let fixture_path = cli.repo_root.join(fixture.fixture_path);
let output = match run_harness_fixture_with_adapter(
&fixture_path,
FixtureOracleAdapter,
fixture_runtime_options(),
) {
Ok(output) => output,
Err(error) => {
return Ok(FixtureSummary::failed(
fixture.name,
started.elapsed().as_millis(),
FailureClassification::ReplayError,
Some(error.to_string()),
));
}
};
let receipt_digest = canonical_receipt_digest(&output.receipt)?;
let root = CheckedReceipt {
oracle_path: cli.repo_root.join(fixture.root_oracle_path),
receipt: &output.receipt,
};
let root_stale = process_receipt(cli, &root)?;
let steps_stale = process_step_oracles(cli, fixture, &output)?;
let digest_stale = check_fixture_digests(cli, fixture, &output.receipt)?;
let failure_classification = if root_stale || steps_stale {
Some(FailureClassification::OracleStale)
} else if digest_stale {
Some(FailureClassification::DigestStale)
} else {
None
};
Ok(FixtureSummary {
name: fixture.name,
status: if failure_classification.is_some() {
SummaryStatus::Failed
} else {
SummaryStatus::Passed
},
elapsed_ms: started.elapsed().as_millis(),
receipt_id: Some(output.receipt.id.to_string()),
receipt_digest: Some(receipt_digest),
failure_classification,
error: None,
})
}
fn process_step_oracles(
cli: &Cli,
fixture: &HarnessFixtureCase,
output: &HarnessReplayOutput,
) -> Result<bool, Box<dyn Error>> {
let mut failed = false;
for (index, step) in fixture.step_oracles.iter().enumerate() {
let receipt = output.step_receipts.get(index).ok_or_else(|| {
MessageError(format!(
"{} did not emit step receipt {}",
fixture.name, step.step_id
))
})?;
let checked = CheckedReceipt {
oracle_path: cli.repo_root.join(step.oracle_path),
receipt,
};
failed |= process_receipt(cli, &checked)?;
}
if output.step_receipts.len() != fixture.step_oracles.len() {
return Err(Box::new(MessageError(format!(
"{} emitted {} step receipts, expected {}",
fixture.name,
output.step_receipts.len(),
fixture.step_oracles.len()
))));
}
Ok(failed)
}
fn process_receipt(cli: &Cli, checked: &CheckedReceipt<'_>) -> Result<bool, Box<dyn Error>> {
let canonical = format!("{}\n", canonical_receipt_json(checked.receipt)?);
if cli.write {
if let Some(parent) = checked.oracle_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&checked.oracle_path, canonical)?;
return Ok(false);
}
let expected = match fs::read_to_string(&checked.oracle_path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
write_stderr_line(&format!(
"missing harness oracle {}",
cli.relative(&checked.oracle_path).display()
))?;
return Ok(true);
}
Err(error) => return Err(Box::new(error)),
};
if expected != canonical {
write_stderr_line(&format!(
"stale harness oracle {}",
cli.relative(&checked.oracle_path).display()
))?;
Ok(true)
} else {
Ok(false)
}
}
fn check_fixture_digests(
cli: &Cli,
fixture: &HarnessFixtureCase,
receipt: &Receipt,
) -> Result<bool, Box<dyn Error>> {
let body_digest = canonical_receipt_body_digest(receipt)?;
let receipt_digest = canonical_receipt_digest(receipt)?;
if cli.write {
write_stdout_line(&format!("{} body_digest={body_digest}", fixture.name))?;
write_stdout_line(&format!("{} receipt_digest={receipt_digest}", fixture.name))?;
return Ok(false);
}
let fixture_path = cli.repo_root.join(fixture.fixture_path);
let contents = fs::read_to_string(&fixture_path)?;
let mut failed = false;
if !contents.contains(&format!("body_digest: {body_digest}")) {
write_stderr_line(&format!(
"stale body_digest in {}",
cli.relative(&fixture_path).display()
))?;
failed = true;
}
if !contents.contains(&format!("receipt_digest: {receipt_digest}")) {
write_stderr_line(&format!(
"stale receipt_digest in {}",
cli.relative(&fixture_path).display()
))?;
failed = true;
}
Ok(failed)
}
fn write_stdout_line(message: &str) -> io::Result<()> {
writeln!(io::stdout().lock(), "{message}")
}
fn write_stderr_line(message: &str) -> io::Result<()> {
writeln!(io::stderr().lock(), "{message}")
}
struct Cli {
repo_root: PathBuf,
write: bool,
summary_json: bool,
}
impl Cli {
fn parse(args: impl Iterator<Item = OsString>) -> Result<Self, Box<dyn Error>> {
let mut repo_root = std::env::current_dir()?;
let mut write = false;
let mut check = false;
let mut summary_json = false;
let mut args = args.peekable();
while let Some(arg) = args.next() {
let token = arg
.to_str()
.ok_or_else(|| MessageError("harness oracle arguments must be UTF-8".to_owned()))?;
match token {
"--write" | "--generate" => write = true,
"--check" => check = true,
"--summary-json" => summary_json = true,
"--repo-root" => {
let value = args
.next()
.ok_or_else(|| MessageError("--repo-root requires a path".to_owned()))?;
repo_root = PathBuf::from(value);
}
"--help" | "-h" => {
return Err(Box::new(MessageError(usage())));
}
_ => {
return Err(Box::new(MessageError(format!(
"unknown harness oracle argument {token}\n{}",
usage()
))));
}
}
}
if write && check {
return Err(Box::new(MessageError(
"use either --write or --check, not both".to_owned(),
)));
}
Ok(Self {
repo_root,
write: write && !check,
summary_json,
})
}
fn relative(&self, path: &Path) -> PathBuf {
path.strip_prefix(&self.repo_root)
.map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
}
}
fn usage() -> String {
"usage: runx-harness-fixture-oracles [--check|--write] [--summary-json] [--repo-root path]"
.to_owned()
}
struct CheckedReceipt<'a> {
oracle_path: PathBuf,
receipt: &'a Receipt,
}
#[derive(serde::Serialize)]
struct SummaryReport {
schema: &'static str,
cases: Vec<FixtureSummary>,
}
#[derive(serde::Serialize)]
struct FixtureSummary {
name: &'static str,
status: SummaryStatus,
elapsed_ms: u128,
receipt_id: Option<String>,
receipt_digest: Option<String>,
failure_classification: Option<FailureClassification>,
error: Option<String>,
}
impl FixtureSummary {
fn failed(
name: &'static str,
elapsed_ms: u128,
failure_classification: FailureClassification,
error: Option<String>,
) -> Self {
Self {
name,
status: SummaryStatus::Failed,
elapsed_ms,
receipt_id: None,
receipt_digest: None,
failure_classification: Some(failure_classification),
error,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum SummaryStatus {
Passed,
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum FailureClassification {
ReplayError,
OracleStale,
DigestStale,
}
#[derive(Debug)]
struct MessageError(String);
impl Display for MessageError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for MessageError {}
fn fixture_runtime_options() -> RuntimeOptions {
RuntimeOptions {
created_at: "2026-05-18T00:00:00Z".to_owned(),
..RuntimeOptions::local_development()
}
}
struct FixtureOracleAdapter;
impl SkillAdapter for FixtureOracleAdapter {
fn adapter_type(&self) -> &'static str {
"cli-tool"
}
fn invoke(&self, request: SkillInvocation) -> Result<SkillOutput, runx_runtime::RuntimeError> {
let message = request
.inputs
.get("message")
.and_then(|value| match value {
JsonValue::String(value) => Some(value.as_str()),
_ => None,
})
.unwrap_or_default()
.to_owned();
let mut claim = JsonObject::default();
claim.insert("message".to_owned(), JsonValue::String(message));
let stdout = serde_json::to_string(&JsonValue::Object(claim)).unwrap_or_default();
Ok(SkillOutput {
status: InvocationStatus::Success,
stdout,
stderr: String::new(),
exit_code: Some(0),
duration_ms: 0,
metadata: JsonObject::default(),
})
}
}