use std::fmt::Write as _;
use std::process::{Command, Stdio};
use serde::Deserialize;
const RETENTION_DRY_RUN_JSON_PREFIX: &str = "AUTUMN_RETENTION_DRY_RUN_REPORT=";
pub struct RetentionOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub profile: &'a str,
pub dry_run: bool,
pub model: Option<&'a str>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RetentionSweepReport {
pub model: String,
pub table: String,
pub rows_swept: u64,
pub duration_ms: u64,
}
pub fn run(opts: &RetentionOptions<'_>) {
if !opts.dry_run {
eprintln!("autumn retention: only --dry-run is supported today.");
eprintln!(
"Declared policies already run on their own fleet-coordinated schedule inside \
the app the moment it boots; there is no separate command to trigger a real \
sweep by hand."
);
std::process::exit(1);
}
eprintln!("\u{1F342} autumn retention --dry-run\n");
crate::routes::compile_binary(opts.package, opts.bin);
let binary = crate::routes::find_binary(opts.package, opts.bin);
let mut command = Command::new(&binary);
clear_competing_one_shot_env(&mut command);
command
.env("AUTUMN_RETENTION_DRY_RUN", "1")
.env("AUTUMN_ENV", opts.profile)
.env("AUTUMN_PROFILE", opts.profile)
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
apply_model_env(&mut command, opts.model);
crate::task::apply_managed_pg_env(&mut command, opts.package);
let output = command.output().unwrap_or_else(|error| {
eprintln!("\u{2717} Failed to run {}: {error}", binary.display());
std::process::exit(1);
});
if !output.status.success() {
eprintln!(
"\u{2717} Binary exited with status {} while running the retention dry-run",
output.status
);
std::process::exit(output.status.code().unwrap_or(1));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let reports: Vec<RetentionSweepReport> = extract_dry_run_json(&stdout).map_or_else(
|| {
eprintln!("Failed to find the retention dry-run report in the binary's output.");
eprintln!("Raw output: {stdout}");
std::process::exit(1);
},
|json| {
serde_json::from_str(json).unwrap_or_else(|error| {
eprintln!("Failed to parse retention dry-run JSON: {error}");
eprintln!("Raw output: {stdout}");
std::process::exit(1);
})
},
);
print!("{}", format_retention_report(&reports));
}
fn clear_competing_one_shot_env(command: &mut Command) {
for var in [
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
] {
command.env_remove(var);
}
}
fn apply_model_env(command: &mut Command, model: Option<&str>) {
if let Some(model) = model {
command.env("AUTUMN_RETENTION_MODEL", model);
} else {
command.env_remove("AUTUMN_RETENTION_MODEL");
}
}
fn extract_dry_run_json(stdout: &str) -> Option<&str> {
stdout
.lines()
.rev()
.find_map(|line| line.strip_prefix(RETENTION_DRY_RUN_JSON_PREFIX))
}
pub fn format_retention_report(reports: &[RetentionSweepReport]) -> String {
if reports.is_empty() {
return "No retention(...) policies are registered.\n".to_string();
}
let mut sorted: Vec<&RetentionSweepReport> = reports.iter().collect();
sorted.sort_by(|a, b| a.model.cmp(&b.model).then_with(|| a.table.cmp(&b.table)));
let model_width = sorted
.iter()
.map(|r| r.model.len())
.max()
.unwrap_or("Model".len())
.max("Model".len());
let table_width = sorted
.iter()
.map(|r| r.table.len())
.max()
.unwrap_or("Table".len())
.max("Table".len());
let mut out = String::new();
let _ = writeln!(
out,
"{:<model_width$} {:<table_width$} Rows that would be swept Duration (ms)",
"Model", "Table"
);
let _ = writeln!(
out,
"{:-<model_width$} {:-<table_width$} ------------------------ -------------",
"", ""
);
for report in &sorted {
let _ = writeln!(
out,
"{:<model_width$} {:<table_width$} {:<24} {}",
report.model, report.table, report.rows_swept, report.duration_ms
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_dry_run_json_finds_the_framed_report_line() {
let stdout = "2026-08-17T12:00:00Z INFO booting\nRunning migration 0001\n\
AUTUMN_RETENTION_DRY_RUN_REPORT=[{\"model\":\"Widget\"}]\n";
assert_eq!(
extract_dry_run_json(stdout),
Some("[{\"model\":\"Widget\"}]")
);
}
#[test]
fn extract_dry_run_json_ignores_incidental_stdout_noise() {
let stdout = "Running migration 20260101000000_create_widgets\n\
{\"level\":\"INFO\",\"message\":\"listening\"}\n\
AUTUMN_RETENTION_DRY_RUN_REPORT=[]\n";
assert_eq!(extract_dry_run_json(stdout), Some("[]"));
}
#[test]
fn extract_dry_run_json_returns_none_without_a_framed_line() {
let stdout = "some unrelated log output\nno report line here\n";
assert_eq!(extract_dry_run_json(stdout), None);
}
#[test]
fn clear_competing_one_shot_env_removes_every_var_checked_before_retention_dry_run() {
let competing_vars = [
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
];
let mut command = std::process::Command::new("true");
for var in competing_vars {
command.env(var, "1");
}
clear_competing_one_shot_env(&mut command);
for var in competing_vars {
let value = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new(var));
assert_eq!(
value,
Some((std::ffi::OsStr::new(var), None)),
"{var} must be explicitly removed (None value), not merely absent from \
overrides, or it would still be inherited from the parent environment: \
{value:?}"
);
}
}
#[test]
fn apply_model_env_removes_an_inherited_var_when_no_model_given() {
let mut command = std::process::Command::new("true");
command.env("AUTUMN_RETENTION_MODEL", "LeftoverFromParentEnv");
apply_model_env(&mut command, None);
let value = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new("AUTUMN_RETENTION_MODEL"));
assert_eq!(
value,
Some((std::ffi::OsStr::new("AUTUMN_RETENTION_MODEL"), None)),
"the variable must be explicitly removed (None value), not merely absent from \
overrides, or it would still be inherited from the parent environment: {value:?}"
);
}
#[test]
fn apply_model_env_sets_the_requested_model() {
let mut command = std::process::Command::new("true");
apply_model_env(&mut command, Some("Widget"));
let value = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new("AUTUMN_RETENTION_MODEL"));
assert_eq!(
value,
Some((
std::ffi::OsStr::new("AUTUMN_RETENTION_MODEL"),
Some(std::ffi::OsStr::new("Widget"))
))
);
}
#[test]
fn format_retention_report_includes_model_and_row_count() {
let table = format_retention_report(&[RetentionSweepReport {
model: "Widget".to_string(),
table: "widgets".to_string(),
rows_swept: 42,
duration_ms: 7,
}]);
assert!(table.contains("Widget"));
assert!(table.contains("widgets"));
assert!(table.contains("42"));
}
#[test]
fn format_retention_report_sorts_by_model_name() {
let table = format_retention_report(&[
RetentionSweepReport {
model: "Zeta".to_string(),
table: "zetas".to_string(),
rows_swept: 1,
duration_ms: 1,
},
RetentionSweepReport {
model: "Alpha".to_string(),
table: "alphas".to_string(),
rows_swept: 2,
duration_ms: 2,
},
]);
let alpha_pos = table.find("Alpha").expect("Alpha present");
let zeta_pos = table.find("Zeta").expect("Zeta present");
assert!(alpha_pos < zeta_pos, "Alpha must sort before Zeta: {table}");
}
#[test]
fn format_retention_report_disambiguates_same_model_name_by_table() {
let table = format_retention_report(&[
RetentionSweepReport {
model: "Session".to_string(),
table: "admin_sessions".to_string(),
rows_swept: 1,
duration_ms: 1,
},
RetentionSweepReport {
model: "Session".to_string(),
table: "auth_sessions".to_string(),
rows_swept: 2,
duration_ms: 2,
},
]);
assert!(table.contains("admin_sessions"));
assert!(table.contains("auth_sessions"));
}
#[test]
fn format_retention_report_reports_no_policies_when_empty() {
let table = format_retention_report(&[]);
assert!(table.contains("No retention"));
}
}