use anyhow::{Result, bail};
use clap::Args;
use bob::db::Database;
use bob::try_println;
#[derive(Debug, Args)]
pub struct PruneArgs {
range: Option<String>,
#[arg(long, conflicts_with_all = ["range", "older_than"])]
keep_last: Option<usize>,
#[arg(long, conflicts_with_all = ["range", "keep_last"])]
older_than: Option<String>,
#[arg(short = 'n', long)]
dry_run: bool,
}
pub fn run(db: &Database, args: PruneArgs) -> Result<()> {
let current = db.build_id().ok();
let builds = db.history_build_ids()?;
let to_drop = if let Some(range) = args.range.as_deref() {
select_range(&builds, range)?
} else if let Some(n) = args.keep_last {
select_keep_last(&builds, n)
} else if let Some(dur) = args.older_than.as_deref() {
select_older_than(&builds, dur)?
} else {
bail!("specify a range, --keep-last, or --older-than");
};
if let Some(cur) = current.as_deref()
&& to_drop.iter().any(|b| b == cur)
{
bail!("refusing to prune current build_id: {cur}");
}
if !args.dry_run {
db.prune_builds(&to_drop)?;
}
for id in &to_drop {
if !try_println(id) {
return Ok(());
}
}
Ok(())
}
fn select_range(builds: &[String], range: &str) -> Result<Vec<String>> {
let (lower, upper) = if let Some((lhs, rhs)) = range.split_once("..") {
if rhs.is_empty() {
bail!("open-ended ranges (X..) are not supported");
}
let lower = if lhs.is_empty() {
None
} else {
Some(normalize_endpoint(lhs, Bound::Lower)?)
};
let upper = Some(normalize_endpoint(rhs, Bound::Upper)?);
(lower, upper)
} else {
(
Some(normalize_endpoint(range, Bound::Lower)?),
Some(normalize_endpoint(range, Bound::Upper)?),
)
};
let mut out: Vec<String> = builds
.iter()
.filter(|b| {
lower.as_deref().is_none_or(|l| b.as_str() >= l)
&& upper.as_deref().is_none_or(|u| b.as_str() <= u)
})
.cloned()
.collect();
out.reverse();
Ok(out)
}
fn select_keep_last(builds: &[String], n: usize) -> Vec<String> {
let mut out: Vec<String> = builds.iter().skip(n).cloned().collect();
out.reverse();
out
}
fn select_older_than(builds: &[String], dur: &str) -> Result<Vec<String>> {
let secs = bob::parse_duration_secs(dur).map_err(|e| anyhow::anyhow!(e))?;
let cutoff = chrono::Utc::now() - chrono::Duration::seconds(secs);
let cutoff_id = cutoff.format(bob::BUILD_ID_FORMAT).to_string();
let mut out: Vec<String> = builds
.iter()
.filter(|b| b.as_str() < cutoff_id.as_str())
.cloned()
.collect();
out.reverse();
Ok(out)
}
#[derive(Clone, Copy)]
enum Bound {
Lower,
Upper,
}
fn normalize_endpoint(s: &str, bound: Bound) -> Result<String> {
if bob::parse_build_id(s).is_some() {
return Ok(s.to_string());
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let suffix = match bound {
Bound::Lower => "T000000Z",
Bound::Upper => "T235959Z",
};
return Ok(format!("{}{}", date.format("%Y%m%d"), suffix));
}
bail!(
"invalid endpoint '{}': expected build_id (YYYYMMDDTHHMMSSZ) or date (YYYY-MM-DD)",
s
);
}