use std::fmt::Write as _;
use std::process::{Command, Stdio};
use serde::Deserialize;
const RETENTION_JSON_PREFIX: &str = "AUTUMN_DB_RETENTION_REPORT=";
const RETENTION_MODE_ENV: &str = "AUTUMN_DB_RETENTION";
const RETENTION_DATASET_ENV: &str = "AUTUMN_DB_RETENTION_DATASET";
const RETENTION_FORCE_ENV: &str = "AUTUMN_DB_RETENTION_FORCE";
const RETENTION_ONE_SHOT_ENV: [&str; 4] = [
RETENTION_MODE_ENV,
RETENTION_DATASET_ENV,
RETENTION_FORCE_ENV,
"AUTUMN_RETENTION_DRY_RUN",
];
pub fn clear_inherited_one_shot_env(command: &mut std::process::Command) {
for var in RETENTION_ONE_SHOT_ENV {
command.env_remove(var);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionMode {
Report,
DryRun,
Purge,
}
impl RetentionMode {
const fn env_value(self) -> &'static str {
match self {
Self::Report | Self::DryRun => "report",
Self::Purge => "purge",
}
}
}
pub struct RetentionOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub profile: &'a str,
pub mode: RetentionMode,
pub dataset: Option<&'a str>,
pub force: bool,
pub json: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RetentionDatasetReport {
pub dataset: String,
pub description: String,
pub enforcement: String,
pub window_secs: Option<u64>,
pub source: String,
pub cutoff: Option<String>,
pub eligible_rows: Option<u64>,
pub rows_removed: u64,
#[serde(default)]
pub truncated: bool,
pub dry_run: bool,
pub skipped: Option<String>,
pub duration_ms: u64,
pub error: Option<String>,
}
pub fn run(opts: &RetentionOptions<'_>) {
eprintln!("\u{1F342} autumn db retention\n");
if let Some(refusal) = production_refusal(opts) {
eprintln!("\u{2717} {refusal}");
std::process::exit(1);
}
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(RETENTION_MODE_ENV, opts.mode.env_value())
.env("AUTUMN_ENV", opts.profile)
.env("AUTUMN_PROFILE", opts.profile)
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
apply_dataset_env(&mut command, opts.dataset);
apply_force_env(&mut command, opts.force);
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);
});
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(json) = extract_report_json(&stdout) else {
eprintln!("Failed to find the retention report in the binary's output.");
eprintln!(
"Last output from the app:\n{}",
tail_for_diagnostics(&stdout)
);
std::process::exit(output.status.code().filter(|code| *code != 0).unwrap_or(1));
};
if opts.json {
println!("{json}");
} else {
let reports: Vec<RetentionDatasetReport> =
serde_json::from_str(json).unwrap_or_else(|error| {
eprintln!("Failed to parse the retention report JSON: {error}");
eprintln!("Raw output: {stdout}");
std::process::exit(1);
});
print!("{}", format_report(&reports, opts.mode));
}
if !output.status.success() {
std::process::exit(output.status.code().unwrap_or(1));
}
}
fn production_refusal(opts: &RetentionOptions<'_>) -> Option<String> {
if opts.mode != RetentionMode::Purge || opts.force {
return None;
}
let profile = crate::migrate::canonical_profile(opts.profile);
if matches!(profile.as_str(), "dev" | "test") {
return None;
}
Some(format!(
"Refusing to purge framework data against the {profile:?} profile.\n \
The configured [retention] policy already runs automatically inside the app; \
re-run with --force if you really mean to purge now."
))
}
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_GRAPH",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
"AUTUMN_RETENTION_DRY_RUN",
] {
command.env_remove(var);
}
}
fn apply_force_env(command: &mut Command, force: bool) {
if force {
command.env(RETENTION_FORCE_ENV, "1");
} else {
command.env_remove(RETENTION_FORCE_ENV);
}
}
fn apply_dataset_env(command: &mut Command, dataset: Option<&str>) {
if let Some(dataset) = dataset {
command.env(RETENTION_DATASET_ENV, dataset);
} else {
command.env_remove(RETENTION_DATASET_ENV);
}
}
fn extract_report_json(stdout: &str) -> Option<&str> {
stdout
.lines()
.rev()
.find_map(|line| line.strip_prefix(RETENTION_JSON_PREFIX))
}
fn tail_for_diagnostics(stdout: &str) -> String {
const LINES: usize = 20;
let lines: Vec<&str> = stdout.lines().collect();
let start = lines.len().saturating_sub(LINES);
lines[start..].join("\n")
}
fn format_window(secs: Option<u64>) -> String {
let Some(secs) = secs else {
return "forever".to_owned();
};
for (unit_secs, suffix) in [(86_400, 'd'), (3_600, 'h'), (60, 'm')] {
if secs % unit_secs == 0 && secs >= unit_secs {
return format!("{}{suffix}", secs / unit_secs);
}
}
format!("{secs}s")
}
fn format_rows(report: &RetentionDatasetReport, mode: RetentionMode) -> String {
if let Some(error) = report.error.as_deref() {
if report.rows_removed > 0 {
return format!("removed {}, then error: {error}", report.rows_removed);
}
return format!("error: {error}");
}
if mode == RetentionMode::Purge && !report.dry_run {
if report.truncated {
return format!("removed {} (more remain)", report.rows_removed);
}
return format!("removed {}", report.rows_removed);
}
report
.eligible_rows
.map_or_else(|| "\u{2014}".to_owned(), |rows| rows.to_string())
}
#[must_use]
pub fn format_report(reports: &[RetentionDatasetReport], mode: RetentionMode) -> String {
if reports.is_empty() {
return "No framework-owned datasets are registered.\n".to_owned();
}
let rows: Vec<(String, String, String, String, String)> = reports
.iter()
.map(|report| {
(
report.dataset.clone(),
format_window(report.window_secs),
report.source.clone(),
report.enforcement.clone(),
format_rows(report, mode),
)
})
.collect();
let rows_header = match mode {
RetentionMode::Report => "Eligible now",
RetentionMode::DryRun => "Would remove",
RetentionMode::Purge => "Result",
};
let headers = ("Dataset", "Retention", "Source", "Enforced by", rows_header);
let width =
|header: &str, extract: fn(&(String, String, String, String, String)) -> &String| {
rows.iter()
.map(|row| extract(row).chars().count())
.max()
.unwrap_or(0)
.max(header.chars().count())
};
let w0 = width(headers.0, |r| &r.0);
let w1 = width(headers.1, |r| &r.1);
let w2 = width(headers.2, |r| &r.2);
let w3 = width(headers.3, |r| &r.3);
let mut out = String::new();
let _ = writeln!(
out,
"{:<w0$} {:<w1$} {:<w2$} {:<w3$} {}",
headers.0, headers.1, headers.2, headers.3, headers.4
);
let _ = writeln!(
out,
"{:-<w0$} {:-<w1$} {:-<w2$} {:-<w3$} {:-<12}",
"", "", "", "", ""
);
for row in &rows {
let _ = writeln!(
out,
"{:<w0$} {:<w1$} {:<w2$} {:<w3$} {}",
row.0, row.1, row.2, row.3, row.4
);
}
if let [only] = reports {
let _ = writeln!(out, "\n {}", only.description);
let _ = writeln!(
out,
" Cutoff: {}",
only.cutoff.as_deref().unwrap_or("\u{2014} (kept forever)")
);
let _ = writeln!(out, " Took: {}ms", only.duration_ms);
}
let notes: Vec<&RetentionDatasetReport> = reports
.iter()
.filter(|report| report.skipped.is_some())
.collect();
if !notes.is_empty() {
out.push('\n');
for report in notes {
if let Some(skipped) = report.skipped.as_deref() {
let _ = writeln!(out, " \u{2139} {}: {skipped}", report.dataset);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn report(dataset: &str) -> RetentionDatasetReport {
RetentionDatasetReport {
dataset: dataset.to_owned(),
description: "a dataset".to_owned(),
enforcement: "sweep".to_owned(),
window_secs: Some(7_776_000),
source: "[retention]".to_owned(),
cutoff: Some("2026-06-01T00:00:00Z".to_owned()),
eligible_rows: Some(42),
rows_removed: 0,
truncated: false,
dry_run: true,
skipped: None,
duration_ms: 3,
error: None,
}
}
#[test]
fn the_engines_report_deserializes_into_this_crates_mirror() {
let engine = autumn_web::data_retention::RetentionDatasetReport {
dataset: "job_history".to_owned(),
description: "Finished job rows".to_owned(),
enforcement: "sweep".to_owned(),
window_secs: Some(7_776_000),
source: "[retention]".to_owned(),
cutoff: Some("2026-06-01T00:00:00Z".to_owned()),
eligible_rows: Some(9),
rows_removed: 9,
truncated: false,
dry_run: false,
skipped: None,
duration_ms: 4,
error: None,
};
let json = serde_json::to_string(&[&engine]).expect("serialize");
let decoded: Vec<RetentionDatasetReport> =
serde_json::from_str(&json).expect("the CLI mirror must decode the engine's report");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].dataset, "job_history");
assert_eq!(decoded[0].window_secs, Some(7_776_000));
assert_eq!(decoded[0].eligible_rows, Some(9));
assert_eq!(decoded[0].rows_removed, 9);
assert!(!decoded[0].truncated);
assert_eq!(decoded[0].cutoff.as_deref(), Some("2026-06-01T00:00:00Z"));
}
#[test]
fn extract_report_json_finds_the_framed_line() {
let stdout = "2026-08-31T12:00:00Z INFO booting\nRunning migration 0001\n\
AUTUMN_DB_RETENTION_REPORT=[{\"dataset\":\"job_history\"}]\n";
assert_eq!(
extract_report_json(stdout),
Some("[{\"dataset\":\"job_history\"}]")
);
}
#[test]
fn extract_report_json_returns_none_without_a_framed_line() {
assert_eq!(extract_report_json("just some logs\n"), None);
}
#[test]
fn apply_force_env_sets_or_removes_the_variable() {
let env_of = |force| {
let mut command = Command::new("true");
command.env(RETENTION_FORCE_ENV, "leftover");
apply_force_env(&mut command, force);
command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new(RETENTION_FORCE_ENV))
.and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
};
assert_eq!(env_of(true).as_deref(), Some("1"));
assert_eq!(env_of(false), None, "an inherited value must be removed");
}
#[test]
fn apply_dataset_env_removes_an_inherited_var_when_no_dataset_given() {
let mut command = Command::new("true");
command.env(RETENTION_DATASET_ENV, "leftover");
apply_dataset_env(&mut command, None);
let value = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new(RETENTION_DATASET_ENV));
assert_eq!(
value,
Some((std::ffi::OsStr::new(RETENTION_DATASET_ENV), None)),
"the variable must be explicitly removed, not merely absent: {value:?}"
);
}
#[test]
fn apply_dataset_env_sets_the_requested_dataset() {
let mut command = Command::new("true");
apply_dataset_env(&mut command, Some("job_history"));
let value = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new(RETENTION_DATASET_ENV));
assert_eq!(
value,
Some((
std::ffi::OsStr::new(RETENTION_DATASET_ENV),
Some(std::ffi::OsStr::new("job_history"))
))
);
}
#[test]
fn clear_competing_one_shot_env_removes_every_earlier_mode_var() {
let competing = [
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_DUMP_CACHE_COHERENCE",
"AUTUMN_DUMP_DATA_FLOW",
"AUTUMN_DUMP_GRAPH",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
"AUTUMN_RETENTION_DRY_RUN",
];
let mut command = Command::new("true");
for var in competing {
command.env(var, "1");
}
clear_competing_one_shot_env(&mut command);
for var in competing {
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: {value:?}"
);
}
}
#[test]
fn report_and_dry_run_both_ask_the_app_only_to_count() {
assert_eq!(RetentionMode::Report.env_value(), "report");
assert_eq!(RetentionMode::DryRun.env_value(), "report");
assert_eq!(RetentionMode::Purge.env_value(), "purge");
}
#[test]
fn format_window_prefers_the_largest_whole_unit() {
assert_eq!(format_window(Some(7_776_000)), "90d");
assert_eq!(format_window(Some(3_600)), "1h");
assert_eq!(format_window(Some(1_800)), "30m");
assert_eq!(format_window(Some(90)), "90s");
assert_eq!(format_window(None), "forever");
}
#[test]
fn format_report_lists_every_dataset_including_unconfigured_ones() {
let mut unconfigured = report("audit_archives");
unconfigured.window_secs = None;
unconfigured.source = "unset".to_owned();
unconfigured.eligible_rows = None;
unconfigured.skipped = Some("no retention window configured".to_owned());
let table = format_report(
&[report("job_history"), unconfigured],
RetentionMode::Report,
);
assert!(table.contains("job_history"), "{table}");
assert!(table.contains("audit_archives"), "{table}");
assert!(table.contains("forever"), "{table}");
assert!(table.contains("90d"), "{table}");
assert!(table.contains("Eligible now"), "{table}");
}
#[test]
fn format_report_shows_a_legal_hold_reason_verbatim() {
let mut held = report("job_history");
held.skipped = Some("legal hold: SOX \u{2014} 7 year retention".to_owned());
held.eligible_rows = None;
let table = format_report(&[held], RetentionMode::Report);
assert!(
table.contains("legal hold: SOX \u{2014} 7 year retention"),
"the operator's own hold reason must be shown in full: {table}"
);
}
#[test]
fn format_report_reports_removals_after_a_purge() {
let mut purged = report("job_history");
purged.dry_run = false;
purged.rows_removed = 17;
let table = format_report(&[purged], RetentionMode::Purge);
assert!(table.contains("removed 17"), "{table}");
assert!(table.contains("Result"), "{table}");
}
#[test]
fn format_report_flags_a_truncated_purge() {
let mut partial = report("job_history");
partial.dry_run = false;
partial.rows_removed = 500_000;
partial.truncated = true;
let table = format_report(&[partial], RetentionMode::Purge);
assert!(
table.contains("more remain"),
"a run that hit its batch cap must not read as fully enforced: {table}"
);
}
#[test]
fn format_report_surfaces_a_per_dataset_error() {
let mut failed = report("job_history");
failed.error = Some("connection refused".to_owned());
let table = format_report(&[failed], RetentionMode::Report);
assert!(table.contains("error: connection refused"), "{table}");
}
#[test]
fn a_partial_purge_reports_what_it_removed_before_it_failed() {
let mut partial = report("job_history");
partial.error = Some("statement timeout".to_owned());
partial.rows_removed = 4_812;
partial.dry_run = false;
let table = format_report(&[partial], RetentionMode::Purge);
assert!(
table.contains("4812"),
"the rows already deleted must survive into the failure line: {table}"
);
assert!(
table.contains("statement timeout"),
"the failure itself must still be reported: {table}"
);
}
#[test]
fn an_error_with_nothing_removed_stays_a_plain_error() {
let mut failed = report("job_history");
failed.error = Some("connection refused".to_owned());
failed.rows_removed = 0;
failed.dry_run = false;
let table = format_report(&[failed], RetentionMode::Purge);
assert!(table.contains("error: connection refused"), "{table}");
assert!(
!table.contains("removed 0"),
"a purge that deleted nothing must not imply it removed rows: {table}"
);
}
#[test]
fn a_single_dataset_report_explains_what_it_is_and_the_exact_cutoff() {
let table = format_report(&[report("job_history")], RetentionMode::Report);
assert!(table.contains("a dataset"), "{table}");
assert!(table.contains("2026-06-01T00:00:00Z"), "{table}");
assert!(table.contains("Took:"), "{table}");
}
#[test]
fn a_multi_dataset_report_omits_the_per_dataset_detail_block() {
let table = format_report(
&[report("job_history"), report("job_tracking")],
RetentionMode::Report,
);
assert!(
!table.contains("Took:"),
"the detail block is only useful for a single-dataset report: {table}"
);
}
#[test]
fn format_report_handles_an_empty_report() {
assert!(format_report(&[], RetentionMode::Report).contains("No framework-owned datasets"));
}
#[test]
fn dry_run_header_says_would_remove() {
let table = format_report(&[report("job_history")], RetentionMode::DryRun);
assert!(table.contains("Would remove"), "{table}");
}
fn purge_opts(profile: &str, force: bool) -> RetentionOptions<'_> {
RetentionOptions {
package: None,
bin: None,
profile,
mode: RetentionMode::Purge,
dataset: None,
force,
json: false,
}
}
#[test]
fn purge_is_refused_for_the_profile_the_child_will_actually_boot() {
let refusal = production_refusal(&purge_opts("prod", false))
.expect("a purge against prod without --force must be refused");
assert!(
refusal.contains("prod"),
"the refusal must name the profile it is protecting: {refusal}"
);
assert!(
production_refusal(&purge_opts("production", false)).is_some(),
"`production` is the same profile as `prod` and must refuse too"
);
}
#[test]
fn a_development_profile_is_recognized_by_its_long_spelling() {
for profile in ["dev", "development", "DEV", "Development", "test"] {
assert!(
production_refusal(&purge_opts(profile, false)).is_none(),
"--profile {profile} is a non-production profile and must not be refused"
);
}
}
#[test]
fn force_overrides_the_refusal_and_non_purge_modes_never_trip_it() {
assert!(
production_refusal(&purge_opts("prod", true)).is_none(),
"--force is the documented escape hatch for a deliberate prod purge"
);
for mode in [RetentionMode::Report, RetentionMode::DryRun] {
let opts = RetentionOptions {
mode,
..purge_opts("prod", false)
};
assert!(
production_refusal(&opts).is_none(),
"{mode:?} deletes nothing, so it has nothing to refuse"
);
}
}
#[test]
fn clear_inherited_one_shot_env_removes_every_retention_mode_var() {
let mut command = std::process::Command::new("true");
for var in RETENTION_ONE_SHOT_ENV {
command.env(var, "purge");
}
clear_inherited_one_shot_env(&mut command);
for var in RETENTION_ONE_SHOT_ENV {
let entry = command
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(var));
assert_eq!(
entry,
Some((std::ffi::OsStr::new(var), None)),
"{var} must be explicitly REMOVED (present with a None value); \
merely absent from the overrides means it is inherited",
);
}
}
}