use crate::cli::CleanupArgs;
use crate::config::PipelineConfig;
use crate::error::{CliError, CliResult};
use crate::local_outputs::{
DEFAULT_RETENTION_DAYS, SkipReason, SweepReport, SweepScope,
sweep::{self, SweepOptions},
};
use crate::serve::history::RunHistory;
use std::sync::Arc;
pub async fn run(args: CleanupArgs) -> CliResult<()> {
let scope = resolve_scope(&args)?;
if scope.requires_confirmation() && !args.yes && !args.dry_run {
return Err(CliError::Config(format!(
"cleanup: scope `{}` deletes tracked local outputs that are still inside \
their retention window (`--older-than-days 0` matches every output, same \
as `--all`). Re-run with --yes to confirm, or with --dry-run to see what \
it would remove.",
scope.label()
)));
}
let (store, retention_days) = connect(&args).await?;
let opts = SweepOptions::new(retention_days)
.dry_run(args.dry_run)
.in_flight_grace(std::time::Duration::from_secs(args.in_flight_grace_secs));
let report = sweep::run(store.as_ref(), &scope, &opts)
.await
.map_err(|e| CliError::Internal(format!("local-output ledger: {e}")))?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| CliError::Internal(format!("rendering cleanup JSON: {e}")))?
);
} else {
print_report(&report, retention_days);
}
Ok(())
}
fn resolve_scope(args: &CleanupArgs) -> CliResult<SweepScope> {
if let Some(days) = args.older_than_days {
return Ok(SweepScope::OlderThanDays(days));
}
if let Some(id) = &args.dataset {
return Ok(SweepScope::Dataset(id.clone()));
}
if let Some(id) = &args.run {
return Ok(SweepScope::Run(id.clone()));
}
if let Some(id) = &args.output {
return Ok(SweepScope::Output(id.clone()));
}
if args.all {
return Ok(SweepScope::All);
}
Ok(SweepScope::Expired)
}
async fn connect(args: &CleanupArgs) -> CliResult<(Arc<dyn RunHistory>, u32)> {
if let Some(url) = &args.store {
let spec = crate::catalog::CatalogSpec {
url: url.clone(),
sample_records: crate::catalog::DEFAULT_SAMPLE_RECORDS,
datasets: Vec::new(),
};
let handle = crate::catalog::connect_from_spec(&spec).await?;
return Ok((
handle.store,
args.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS),
));
}
let cwd = std::env::current_dir()?;
let env_path =
crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
let path = match &args.config {
Some(p) => p.clone(),
None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
};
let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
let spec = cfg.catalog.as_ref().ok_or_else(|| {
CliError::Config(
"no `catalog:` block in this config, so there is no ledger of local outputs to \
clean. Add one naming the store (e.g. `catalog: { url: sqlite:./faucet-catalog.db }`) \
and re-run the pipeline, or pass --store <url> to point at an existing store."
.to_string(),
)
})?;
let handle = crate::catalog::connect_from_spec(spec).await?;
let retention_days = args
.retention_days
.or_else(|| {
cfg.local_outputs
.as_ref()
.and_then(|spec| spec.retention_days)
})
.unwrap_or(DEFAULT_RETENTION_DAYS);
Ok((handle.store, retention_days))
}
fn print_report(report: &SweepReport, retention_days: u32) {
let verb = if report.dry_run {
"would delete"
} else {
"deleted"
};
if report.outputs.is_empty() {
match report.scope.as_str() {
"expired" => println!(
"nothing to clean — no tracked local output is older than {retention_days} day(s)"
),
_ => println!("nothing to clean — no tracked local output matched"),
}
return;
}
for o in &report.outputs {
match o.skipped {
None => println!(" {} {} ({})", verb, o.path, human_bytes(o.bytes)),
Some(reason) => println!(" skipped {} — {}", o.path, explain(reason, o)),
}
}
println!(
"{} {} file(s), {}{}",
verb,
report.deleted,
human_bytes(report.bytes),
if report.skipped > 0 {
format!("; {} skipped", report.skipped)
} else {
String::new()
}
);
if report.dry_run {
println!("(dry run — nothing was removed)");
}
}
fn explain(reason: SkipReason, outcome: &crate::local_outputs::SweepOutcome) -> String {
match reason {
SkipReason::PreExisting => {
"faucet wrote this file but did not create it, so it is never deleted".to_string()
}
SkipReason::AlreadyDeleted => "already cleaned (the record is kept as expired)".to_string(),
SkipReason::NotOnDisk => "already gone from disk; marked expired".to_string(),
SkipReason::InFlight => "a run is still writing it; will be retried".to_string(),
SkipReason::DeleteFailed => format!(
"could not delete: {}",
outcome.error.as_deref().unwrap_or("unknown error")
),
}
}
fn human_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
match bytes {
b if b >= GB => format!("{:.1} GiB", b as f64 / GB as f64),
b if b >= MB => format!("{:.1} MiB", b as f64 / MB as f64),
b if b >= KB => format!("{:.1} KiB", b as f64 / KB as f64),
b => format!("{b} B"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local_outputs::SweepOutcome;
fn args() -> CleanupArgs {
CleanupArgs {
config: None,
env_file: None,
no_env_file: false,
profile: None,
json: false,
store: None,
older_than_days: None,
dataset: None,
run: None,
output: None,
in_flight_grace_secs: 60,
all: false,
retention_days: None,
dry_run: false,
yes: false,
}
}
#[test]
fn the_bare_invocation_is_the_retention_sweep() {
assert_eq!(resolve_scope(&args()).unwrap(), SweepScope::Expired);
}
#[test]
fn each_flag_resolves_to_its_scope() {
let mut a = args();
a.older_than_days = Some(3);
assert_eq!(resolve_scope(&a).unwrap(), SweepScope::OlderThanDays(3));
let mut a = args();
a.dataset = Some("ds1".into());
assert_eq!(
resolve_scope(&a).unwrap(),
SweepScope::Dataset("ds1".into())
);
let mut a = args();
a.run = Some("run-1".into());
assert_eq!(resolve_scope(&a).unwrap(), SweepScope::Run("run-1".into()));
let mut a = args();
a.output = Some("out1".into());
assert_eq!(
resolve_scope(&a).unwrap(),
SweepScope::Output("out1".into())
);
let mut a = args();
a.all = true;
assert_eq!(resolve_scope(&a).unwrap(), SweepScope::All);
}
#[tokio::test]
async fn a_zero_day_window_needs_the_same_confirmation_as_clean_all() {
let mut a = args();
a.older_than_days = Some(0);
let err = run(a).await.unwrap_err();
match err {
CliError::Config(m) => assert!(m.contains("--yes"), "{m}"),
other => panic!("expected a Config error, got {other:?}"),
}
let mut a = args();
a.older_than_days = Some(1);
let err = run(a).await.unwrap_err();
assert!(
!matches!(&err, CliError::Config(m) if m.contains("--yes")),
"should have passed the confirmation gate, got {err:?}"
);
}
#[tokio::test]
async fn clean_all_without_confirmation_is_refused_before_touching_a_store() {
let mut a = args();
a.all = true;
let err = run(a).await.unwrap_err();
match err {
CliError::Config(m) => {
assert!(m.contains("--yes"), "{m}");
assert!(m.contains("--dry-run"), "{m}");
}
other => panic!("expected a Config error, got {other:?}"),
}
}
#[tokio::test]
async fn clean_all_is_allowed_to_proceed_with_dry_run() {
let mut a = args();
a.all = true;
a.dry_run = true;
let err = run(a).await.unwrap_err();
assert!(
!matches!(&err, CliError::Config(m) if m.contains("--yes")),
"should have passed the confirmation gate, got {err:?}"
);
}
#[test]
fn bytes_render_in_human_units() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(2048), "2.0 KiB");
assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MiB");
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
}
#[test]
fn every_skip_reason_has_a_human_explanation() {
let outcome = SweepOutcome {
id: "i".into(),
path: "/tmp/a".into(),
dataset_uri: "file:///tmp/a".into(),
deleted: false,
bytes: 0,
skipped: None,
error: Some("permission denied".into()),
};
for r in [
SkipReason::PreExisting,
SkipReason::AlreadyDeleted,
SkipReason::NotOnDisk,
SkipReason::InFlight,
SkipReason::DeleteFailed,
] {
let text = explain(r, &outcome);
assert!(!text.is_empty(), "{}", r.as_str());
}
assert!(explain(SkipReason::DeleteFailed, &outcome).contains("permission denied"));
assert!(explain(SkipReason::PreExisting, &outcome).contains("did not create"));
}
#[test]
fn an_empty_retention_sweep_says_so_without_panicking() {
let report = SweepReport {
scope: "expired".into(),
..Default::default()
};
print_report(&report, 7);
let report = SweepReport {
scope: "all".into(),
..Default::default()
};
print_report(&report, 7);
}
}