use super::super::args::{BundleArgs, BundleCreateArgs, BundleSub, BundleVerifyArgs};
use crate::exit_codes;
use anyhow::Context;
use assay_core::replay::{
build_file_manifest, capture_toolchain, scrub_content, verify_bundle, write_bundle_tar_gz,
BundleEntry, ReplayCoverage, ReplayManifest, ReplayOutputs, ReplaySeeds, ScrubPolicy,
};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub async fn run(args: BundleArgs, _legacy_mode: bool) -> anyhow::Result<i32> {
match args.cmd {
BundleSub::Create(c) => cmd_create(c),
BundleSub::Verify(v) => cmd_verify(v),
}
}
fn cmd_verify(args: BundleVerifyArgs) -> anyhow::Result<i32> {
let file = std::fs::File::open(&args.bundle)
.with_context(|| format!("failed to open bundle: {}", args.bundle.display()))?;
let res = verify_bundle(file)?;
for w in &res.warnings {
eprintln!("warning: {}", w);
}
if !res.errors.is_empty() {
for e in &res.errors {
eprintln!("error: {}", e);
}
return Ok(exit_codes::EXIT_CONFIG_ERROR);
}
if res.warnings.is_empty() {
eprintln!("bundle verify: OK ({})", args.bundle.display());
} else {
eprintln!(
"bundle verify: OK with warnings ({}, {} warning(s))",
args.bundle.display(),
res.warnings.len()
);
}
Ok(exit_codes::EXIT_SUCCESS)
}
fn cmd_create(args: BundleCreateArgs) -> anyhow::Result<i32> {
let cwd = std::env::current_dir()?;
let (source_root, selection_method) = select_source_root(&args, &cwd)?;
let mut entries_map: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut outputs = ReplayOutputs {
run: None,
summary: None,
junit: None,
sarif: None,
};
let mut source_run_id: Option<String> = None;
let mut seeds: Option<ReplaySeeds> = None;
let mut replay_coverage: Option<ReplayCoverage> = None;
let mut config_digest: Option<String> = None;
let mut policy_digest: Option<String> = None;
let mut baseline_digest: Option<String> = None;
let mut trace_digest: Option<String> = None;
let mut trace_path: Option<String> = None;
let mut config_snapshot_present = false;
let mut trace_snapshot_present = false;
if let Some(run_json_path) = find_run_json(&source_root, args.from.as_ref()) {
let run_bytes = std::fs::read(&run_json_path)?;
entries_map.insert("outputs/run.json".to_string(), run_bytes.clone());
outputs.run = Some("outputs/run.json".to_string());
if let Ok(v) = serde_json::from_slice::<Value>(&run_bytes) {
source_run_id = extract_run_id(&v);
seeds = extract_seeds(&v);
replay_coverage = extract_replay_coverage(&v);
if let Some(d) = v
.get("provenance")
.and_then(|p| p.get("policy_pack_digest"))
.and_then(|x| x.as_str())
{
policy_digest = Some(d.to_string());
}
if let Some(d) = v
.get("provenance")
.and_then(|p| p.get("baseline_digest"))
.and_then(|x| x.as_str())
{
baseline_digest = Some(d.to_string());
}
if let Some(d) = v
.get("provenance")
.and_then(|p| p.get("trace_digest"))
.and_then(|x| x.as_str())
{
trace_digest = Some(d.to_string());
}
}
}
if let Some(summary_path) = find_summary_json(&source_root) {
let bytes = std::fs::read(&summary_path)?;
entries_map.insert("outputs/summary.json".to_string(), bytes.clone());
outputs.summary = Some("outputs/summary.json".to_string());
if seeds.is_none() {
if let Ok(v) = serde_json::from_slice::<Value>(&bytes) {
seeds = extract_seeds(&v);
}
}
}
if let Some(junit_path) = find_first_existing(
&source_root,
&[
PathBuf::from("junit.xml"),
PathBuf::from(".assay/reports/junit.xml"),
],
) {
let bytes = std::fs::read(&junit_path)?;
entries_map.insert("outputs/junit.xml".to_string(), bytes);
outputs.junit = Some("outputs/junit.xml".to_string());
}
if let Some(sarif_path) = find_first_existing(
&source_root,
&[
PathBuf::from("sarif.json"),
PathBuf::from(".assay/reports/sarif.json"),
],
) {
let bytes = std::fs::read(&sarif_path)?;
entries_map.insert("outputs/sarif.json".to_string(), bytes);
outputs.sarif = Some("outputs/sarif.json".to_string());
}
if let Some(config_path) = select_config_path(&args, &source_root) {
let bytes = std::fs::read(&config_path).with_context(|| {
format!(
"failed to read config for bundle: {}",
config_path.display()
)
})?;
let name = config_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("eval.yaml");
entries_map.insert(format!("files/{}", name), bytes.clone());
config_snapshot_present = true;
let hash = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
if config_digest.is_none() {
config_digest = Some(hash.clone());
}
if policy_digest.is_none() {
policy_digest = Some(hash);
}
}
if let Some(t_path) = select_trace_path(&args, &source_root) {
let bytes = std::fs::read(&t_path)
.with_context(|| format!("failed to read trace for bundle: {}", t_path.display()))?;
let name = t_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("trace.jsonl");
let bundle_trace_path = format!("files/{}", name);
entries_map.insert(bundle_trace_path.clone(), bytes.clone());
trace_snapshot_present = true;
if trace_digest.is_none() {
trace_digest = Some(format!("sha256:{}", hex::encode(Sha256::digest(&bytes))));
}
trace_path = Some(bundle_trace_path);
}
for cassette_dir in cassette_dirs(&source_root) {
if cassette_dir.exists() {
let files = collect_files_recursive(&cassette_dir)?;
for path in files {
let rel = match path.strip_prefix(&cassette_dir) {
Ok(r) => r,
Err(_) => continue,
};
let rel_posix = rel.to_string_lossy().replace('\\', "/");
let bundle_path = format!("cassettes/{}", rel_posix);
let raw = std::fs::read(&path)?;
entries_map
.entry(bundle_path)
.or_insert_with(|| scrub_content(&raw));
}
}
}
let mut entries = Vec::new();
for (path, data) in entries_map {
entries.push(BundleEntry { path, data });
}
replay_coverage = replay_coverage.map(|coverage| {
enforce_bundle_input_coverage(coverage, config_snapshot_present, trace_snapshot_present)
});
let file_manifest = build_file_manifest(&entries)?;
let run_id = args
.run_id
.clone()
.or(source_run_id.clone())
.unwrap_or_else(|| "latest".to_string());
let out_path = args
.output
.clone()
.unwrap_or_else(|| PathBuf::from(format!(".assay/bundles/{}.tar.gz", run_id)));
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut manifest = ReplayManifest::minimal(env!("CARGO_PKG_VERSION").to_string());
manifest.created_at = Some(chrono::Utc::now().to_rfc3339());
manifest.source_run_path = Some(source_root.to_string_lossy().to_string());
manifest.selection_method = Some(selection_method);
if outputs.run.is_some()
|| outputs.summary.is_some()
|| outputs.junit.is_some()
|| outputs.sarif.is_some()
{
manifest.outputs = Some(outputs);
}
manifest.toolchain = Some(capture_toolchain());
manifest.seeds = seeds;
manifest.scrub_policy = Some(ScrubPolicy::default());
manifest.workflow_run_id = std::env::var("GITHUB_RUN_ID").ok();
manifest.config_digest = config_digest;
manifest.trace_path = trace_path;
manifest.trace_digest = trace_digest;
manifest.policy_digest = policy_digest;
manifest.baseline_digest = baseline_digest;
manifest.replay_coverage = replay_coverage;
manifest.files = Some(file_manifest);
let f = std::fs::File::create(&out_path)?;
write_bundle_tar_gz(f, &manifest, &entries)?;
let verify = verify_bundle(std::fs::File::open(&out_path)?)?;
for w in &verify.warnings {
eprintln!("warning: {}", w);
}
if !verify.errors.is_empty() {
for e in &verify.errors {
eprintln!("error: {}", e);
}
anyhow::bail!(
"bundle create failed verification: {} ({} error(s))",
out_path.display(),
verify.errors.len()
);
}
if verify.warnings.is_empty() {
eprintln!("bundle created: {}", out_path.display());
} else {
eprintln!(
"bundle created with warnings: {} ({} warning(s))",
out_path.display(),
verify.warnings.len()
);
}
Ok(exit_codes::EXIT_SUCCESS)
}
fn select_source_root(args: &BundleCreateArgs, cwd: &Path) -> anyhow::Result<(PathBuf, String)> {
if let Some(from) = &args.from {
let p = absolutize(from, cwd);
if !p.exists() {
anyhow::bail!("--from path does not exist: {}", p.display());
}
if p.is_file() {
let parent = p
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf());
return Ok((parent, "explicit-from".to_string()));
}
return Ok((p, "explicit-from".to_string()));
}
if let Some(run_id) = &args.run_id {
for rel in &[
format!(".assay/{}", run_id),
format!(".assay/run_{}", run_id),
format!(".assay/runs/{}", run_id),
] {
let p = cwd.join(rel);
if p.exists() {
return Ok((p, "run-id".to_string()));
}
}
anyhow::bail!(
"--run-id was provided but no matching path exists under .assay for id {}",
run_id
);
}
if let Some(latest) = find_latest_run_json(cwd)? {
let parent = latest
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf());
return Ok((parent, "mtime-latest".to_string()));
}
Ok((cwd.to_path_buf(), "cwd-fallback".to_string()))
}
fn find_latest_run_json(root: &Path) -> anyhow::Result<Option<PathBuf>> {
let candidates = collect_named_files(root, "run.json", 6)?;
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for p in candidates {
let meta = match std::fs::metadata(&p) {
Ok(m) => m,
Err(_) => continue,
};
let mtime = match meta.modified() {
Ok(t) => t,
Err(_) => continue,
};
match &best {
Some((bt, _)) if &mtime <= bt => {}
_ => best = Some((mtime, p)),
}
}
Ok(best.map(|(_, p)| p))
}
fn find_run_json(source_root: &Path, from: Option<&PathBuf>) -> Option<PathBuf> {
if let Some(from) = from {
if from.is_file() && from.file_name().and_then(|x| x.to_str()) == Some("run.json") {
return Some(from.clone());
}
}
find_first_existing(
source_root,
&[PathBuf::from("run.json"), PathBuf::from(".assay/run.json")],
)
}
fn find_summary_json(source_root: &Path) -> Option<PathBuf> {
find_first_existing(
source_root,
&[
PathBuf::from("summary.json"),
PathBuf::from(".assay/summary.json"),
],
)
}
fn select_config_path(args: &BundleCreateArgs, source_root: &Path) -> Option<PathBuf> {
if let Some(cfg) = &args.config {
return Some(cfg.clone());
}
find_first_existing(
source_root,
&[PathBuf::from("eval.yaml"), PathBuf::from("assay.yaml")],
)
}
fn select_trace_path(args: &BundleCreateArgs, source_root: &Path) -> Option<PathBuf> {
if let Some(t) = &args.trace_file {
return Some(t.clone());
}
find_first_existing(
source_root,
&[
PathBuf::from("trace.jsonl"),
PathBuf::from("traces/ci.jsonl"),
PathBuf::from("traces/run.jsonl"),
PathBuf::from("traces/trace.jsonl"),
],
)
}
fn find_first_existing(source_root: &Path, candidates: &[PathBuf]) -> Option<PathBuf> {
for c in candidates {
let p = if c.is_absolute() {
c.clone()
} else {
source_root.join(c)
};
if p.exists() && p.is_file() {
return Some(p);
}
}
None
}
fn collect_named_files(
root: &Path,
needle: &str,
max_depth: usize,
) -> anyhow::Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_named_files_inner(root, needle, max_depth, 0, &mut out)?;
Ok(out)
}
fn collect_named_files_inner(
dir: &Path,
needle: &str,
max_depth: usize,
depth: usize,
out: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
if depth > max_depth {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let p = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if should_skip_recursive_dir(name) {
continue;
}
collect_named_files_inner(&p, needle, max_depth, depth + 1, out)?;
} else if ft.is_file() && p.file_name().and_then(|s| s.to_str()) == Some(needle) {
out.push(p);
}
}
Ok(())
}
fn collect_files_recursive(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut out = Vec::new();
collect_files_recursive_inner(root, &mut out)?;
Ok(out)
}
fn collect_files_recursive_inner(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if should_skip_recursive_dir(name) {
continue;
}
collect_files_recursive_inner(&path, out)?;
} else if ft.is_file() {
out.push(path);
}
}
Ok(())
}
fn should_skip_recursive_dir(name: &str) -> bool {
matches!(
name,
".git" | "target" | "node_modules" | ".venv" | "venv" | "__pycache__" | "dist" | "build"
)
}
fn cassette_dirs(source_root: &Path) -> Vec<PathBuf> {
let mut dirs = vec![
source_root.join("cassettes"),
source_root.join(".assay/cassettes"),
source_root.join(".assay/vcr"),
];
if let Ok(vcr_dir) = std::env::var("ASSAY_VCR_DIR") {
let p = PathBuf::from(vcr_dir);
if p.is_absolute() {
dirs.push(p);
} else {
dirs.push(source_root.join(p));
}
}
dirs
}
fn extract_run_id(v: &Value) -> Option<String> {
if let Some(id) = v.get("run_id") {
if let Some(s) = id.as_str() {
return Some(s.to_string());
}
if let Some(n) = id.as_i64() {
return Some(n.to_string());
}
if let Some(n) = id.as_u64() {
return Some(n.to_string());
}
}
None
}
fn extract_seeds(v: &Value) -> Option<ReplaySeeds> {
if let Some(seeds) = v.get("seeds") {
let seed_version = seeds
.get("seed_version")
.and_then(|x| x.as_u64())
.map(|x| x as u32);
let order_seed = seed_to_string(seeds.get("order_seed"));
let judge_seed = seed_to_string(seeds.get("judge_seed"));
return Some(ReplaySeeds {
seed_version,
order_seed,
judge_seed,
});
}
let seed_version = v
.get("seed_version")
.and_then(|x| x.as_u64())
.map(|x| x as u32);
let order_seed = seed_to_string(v.get("order_seed"));
let judge_seed = seed_to_string(v.get("judge_seed"));
if seed_version.is_some() || order_seed.is_some() || judge_seed.is_some() {
return Some(ReplaySeeds {
seed_version,
order_seed,
judge_seed,
});
}
None
}
fn extract_replay_coverage(v: &Value) -> Option<ReplayCoverage> {
let results = v.get("results")?.as_array()?;
let mut complete_tests = Vec::new();
let mut incomplete_tests = Vec::new();
let mut reason = BTreeMap::new();
for row in results {
let Some(test_id) = row.get("test_id").and_then(|x| x.as_str()) else {
continue;
};
let status = row
.get("status")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_ascii_lowercase();
let msg = row
.get("message")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim();
if status == "error" {
incomplete_tests.push(test_id.to_string());
if !msg.is_empty() {
reason.insert(test_id.to_string(), truncate_reason(msg, 240));
}
} else {
complete_tests.push(test_id.to_string());
}
}
if complete_tests.is_empty() && incomplete_tests.is_empty() {
return None;
}
Some(ReplayCoverage {
complete_tests,
incomplete_tests,
reason: if reason.is_empty() {
None
} else {
Some(reason)
},
})
}
fn enforce_bundle_input_coverage(
mut coverage: ReplayCoverage,
config_snapshot_present: bool,
trace_snapshot_present: bool,
) -> ReplayCoverage {
if !config_snapshot_present {
mark_all_incomplete(&mut coverage, "config snapshot missing from bundle");
}
if !trace_snapshot_present {
mark_all_incomplete(&mut coverage, "trace snapshot missing from bundle");
}
coverage
}
fn mark_all_incomplete(coverage: &mut ReplayCoverage, reason_message: &str) {
let mut all = BTreeSet::new();
for test_id in &coverage.complete_tests {
all.insert(test_id.clone());
}
for test_id in &coverage.incomplete_tests {
all.insert(test_id.clone());
}
if all.is_empty() {
return;
}
let reason = coverage.reason.get_or_insert_with(BTreeMap::new);
for test_id in &all {
reason
.entry(test_id.clone())
.or_insert_with(|| reason_message.to_string());
}
coverage.complete_tests.clear();
coverage.incomplete_tests = all.into_iter().collect();
}
fn truncate_reason(message: &str, max_chars: usize) -> String {
if message.chars().count() <= max_chars {
return message.to_string();
}
message.chars().take(max_chars).collect()
}
fn seed_to_string(v: Option<&Value>) -> Option<String> {
let v = v?;
if v.is_null() {
return None;
}
if let Some(s) = v.as_str() {
return Some(s.to_string());
}
if let Some(n) = v.as_u64() {
return Some(n.to_string());
}
None
}
fn absolutize(path: &Path, cwd: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}