use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
pub const REPLAY_CAPSULE_ENV: &str = "AUTUMN_REPLAY_CAPSULE";
const EXIT_REFUSED: i32 = 2;
fn clear_competing_one_shot_env(command: &mut Command) {
for var in [
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_DUMP_CACHE_COHERENCE",
"AUTUMN_DUMP_DATA_FLOW",
"AUTUMN_DUMP_AGENT_AUTHORITY",
"AUTUMN_DUMP_GRAPH",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
"AUTUMN_RETENTION_DRY_RUN",
"AUTUMN_DB_RETENTION",
"AUTUMN_DATA_CAPSULE",
] {
command.env_remove(var);
}
}
pub struct ReplayOptions<'a> {
pub capsule: &'a str,
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub profile: Option<&'a str>,
pub build: Option<bool>,
pub features: Option<&'a str>,
pub no_default_features: bool,
}
pub fn run(opts: &ReplayOptions<'_>) {
let capsule = resolve_capsule_path(Path::new(opts.capsule)).unwrap_or_else(|error| {
eprintln!("autumn replay: {error}");
std::process::exit(EXIT_REFUSED);
});
let recorded_profile = recorded_profile(&capsule);
let profile = effective_profile(opts.profile, recorded_profile.as_deref());
let release = effective_release(opts.build, recorded_debug_assertions(&capsule));
eprintln!("autumn replay {}\n", capsule.display());
compile_binary(opts, release);
let binary = crate::routes::find_binary_in_profile(
opts.package,
opts.bin,
&crate::routes::CargoProfile::from_release(release),
);
let mut command = Command::new(&binary);
clear_competing_one_shot_env(&mut command);
command
.env(REPLAY_CAPSULE_ENV, &capsule)
.env("AUTUMN_ENV", &profile)
.env("AUTUMN_PROFILE", &profile)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = command.status().unwrap_or_else(|error| {
eprintln!("Failed to run {}: {error}", binary.display());
std::process::exit(1);
});
std::process::exit(exit_code(status));
}
fn recorded_profile(capsule: &Path) -> Option<String> {
let json = std::fs::read_to_string(capsule).ok()?;
let value: serde_json::Value = serde_json::from_str(&json).ok()?;
value
.get("app")?
.get("profile")?
.as_str()
.map(str::to_owned)
}
fn effective_profile(explicit: Option<&str>, recorded: Option<&str>) -> String {
match (explicit, recorded) {
(Some(explicit), Some(recorded)) if explicit != recorded => {
eprintln!(
"warning: replaying with --profile {explicit}, but the capsule was recorded \
under the {recorded:?} profile — profile-gated routes and configuration may \
differ from the failing run"
);
explicit.to_owned()
}
(Some(explicit), _) => explicit.to_owned(),
(None, Some(recorded)) => recorded.to_owned(),
(None, None) => "dev".to_owned(),
}
}
fn recorded_debug_assertions(capsule: &Path) -> Option<bool> {
let json = std::fs::read_to_string(capsule).ok()?;
let value: serde_json::Value = serde_json::from_str(&json).ok()?;
value.get("app")?.get("debug_assertions")?.as_bool()
}
fn effective_release(explicit: Option<bool>, recorded_debug_assertions: Option<bool>) -> bool {
let recorded = recorded_debug_assertions.map(|debug| !debug);
match (explicit, recorded) {
(Some(explicit), Some(recorded)) if explicit != recorded => {
eprintln!(
"warning: replaying with a {} build, but the capsule was recorded by a {} \
build — `cfg(debug_assertions)` code paths and release-only behaviour may \
differ from the failing run",
build_kind(explicit),
build_kind(recorded)
);
explicit
}
(Some(explicit), _) => explicit,
(None, Some(recorded)) => recorded,
(None, None) => false,
}
}
const fn build_kind(release: bool) -> &'static str {
if release { "release" } else { "debug" }
}
fn compile_binary(opts: &ReplayOptions<'_>, release: bool) {
let mut cargo = Command::new("cargo");
cargo.arg("build");
if release {
cargo.arg("--release");
}
if let Some(features) = opts.features {
cargo.args(["--features", features]);
}
if opts.no_default_features {
cargo.arg("--no-default-features");
}
if let Some(pkg) = opts.package {
cargo.args(["-p", pkg]);
}
if let Some(bin) = opts.bin {
cargo.args(["--bin", bin]);
}
let status = crate::built_artifact::run_build(&mut cargo).expect("failed to run cargo build");
if !status.success() {
eprintln!("\u{2717} Compilation failed");
std::process::exit(1);
}
}
fn resolve_capsule_path(path: &Path) -> Result<PathBuf, String> {
if path.is_dir() {
return Err(format!(
"{} is a directory, not a capsule — pass the capsule JSON file inside it",
path.display()
));
}
if !path.is_file() {
return Err(format!(
"no capsule at {} — pass the path to a capsule written by `[failure_capture]` \
(default directory: tmp/autumn-capsules)",
path.display()
));
}
std::fs::canonicalize(path)
.map_err(|error| format!("could not resolve {}: {error}", path.display()))
}
fn exit_code(status: ExitStatus) -> i32 {
status.code().unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replay_defaults_to_the_capsules_recorded_profile() {
assert_eq!(effective_profile(None, Some("prod")), "prod");
assert_eq!(effective_profile(None, None), "dev");
assert_eq!(effective_profile(Some("staging"), Some("prod")), "staging");
assert_eq!(effective_profile(Some("prod"), Some("prod")), "prod");
}
#[test]
fn replay_defaults_to_the_capsules_recorded_build_kind() {
assert!(effective_release(None, Some(false)));
assert!(!effective_release(None, Some(true)));
assert!(!effective_release(None, None));
assert!(effective_release(Some(true), Some(true)));
assert!(!effective_release(Some(false), Some(false)));
}
#[test]
fn recorded_debug_assertions_reads_app_debug_assertions_from_the_capsule_json() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capsule.json");
std::fs::write(
&path,
r#"{"app":{"name":"shop","profile":"prod","debug_assertions":false}}"#,
)
.expect("write");
assert_eq!(recorded_debug_assertions(&path), Some(false));
std::fs::write(&path, r#"{"app":{"name":"shop"}}"#).expect("write");
assert_eq!(
recorded_debug_assertions(&path),
None,
"capsules recorded before the field existed carry no build kind"
);
}
#[test]
fn recorded_profile_reads_app_profile_from_the_capsule_json() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capsule.json");
std::fs::write(&path, r#"{"app":{"name":"shop","profile":"prod"}}"#).expect("write");
assert_eq!(recorded_profile(&path).as_deref(), Some("prod"));
std::fs::write(&path, r#"{"app":{"name":"shop"}}"#).expect("write");
assert_eq!(recorded_profile(&path), None);
std::fs::write(&path, "not json").expect("write");
assert_eq!(
recorded_profile(&path),
None,
"a malformed capsule is the app binary's refusal to make, not ours"
);
}
#[test]
fn resolve_capsule_path_rejects_a_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope.json");
let error = resolve_capsule_path(&missing).expect_err("a missing capsule must be rejected");
assert!(
error.contains("nope.json"),
"the error must name the path: {error}"
);
assert!(
error.contains("tmp/autumn-capsules"),
"the error must point at the capsule directory: {error}"
);
}
#[test]
fn resolve_capsule_path_returns_an_absolute_path() {
let dir = tempfile::tempdir().expect("tempdir");
let capsule = dir.path().join("capsule.json");
std::fs::write(&capsule, "{}").expect("write capsule");
let resolved = resolve_capsule_path(&capsule).expect("an existing capsule resolves");
assert!(
resolved.is_absolute(),
"the child may run elsewhere: {}",
resolved.display()
);
}
#[test]
fn resolve_capsule_path_rejects_a_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let error = resolve_capsule_path(dir.path()).expect_err("a directory is not a capsule");
assert!(
error.contains("directory"),
"the error must say what is wrong: {error}"
);
}
#[test]
fn clear_competing_one_shot_env_removes_every_var_checked_before_replay() {
let competing_vars = [
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_DUMP_CACHE_COHERENCE",
"AUTUMN_DUMP_DATA_FLOW",
"AUTUMN_DUMP_AGENT_AUTHORITY",
"AUTUMN_DUMP_GRAPH",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
"AUTUMN_RETENTION_DRY_RUN",
"AUTUMN_DB_RETENTION",
];
let mut command = Command::new("true");
for var in competing_vars {
command.env(var, "1");
}
clear_competing_one_shot_env(&mut command);
let remaining: Vec<&str> = command
.get_envs()
.filter_map(|(key, value)| {
value
.and_then(|_| key.to_str())
.filter(|k| competing_vars.contains(k))
})
.collect();
assert!(
remaining.is_empty(),
"these competing one-shot vars survived: {remaining:?}"
);
}
#[test]
fn an_inherited_framework_retention_purge_cannot_hijack_a_replay() {
let mut command = Command::new("true");
command.env("AUTUMN_DB_RETENTION", "purge");
clear_competing_one_shot_env(&mut command);
let survived = command
.get_envs()
.any(|(key, value)| key == "AUTUMN_DB_RETENTION" && value.is_some());
assert!(
!survived,
"an inherited AUTUMN_DB_RETENTION=purge must never survive into a replay"
);
}
#[cfg(unix)]
#[test]
fn the_child_verdict_exit_code_is_forwarded_verbatim() {
use std::os::unix::process::ExitStatusExt as _;
assert_eq!(exit_code(ExitStatus::from_raw(0)), 0);
assert_eq!(exit_code(ExitStatus::from_raw(1 << 8)), 1);
assert_eq!(exit_code(ExitStatus::from_raw(2 << 8)), 2);
assert_eq!(exit_code(ExitStatus::from_raw(9)), 1);
}
}