use std::collections::BTreeSet;
use std::num::NonZeroUsize;
use crate::cli::Error;
use crate::cli::model::Commands;
use crate::cli::output::Render;
use crate::cli::output::Std;
use quilt_rs::InstalledPackage;
use quilt_rs::flow::HistoryEntry;
use quilt_rs::flow::RemovalReport;
use quilt_rs::io::remote::Remote;
use quilt_rs::io::storage::Storage;
use quilt_rs::lineage::PackageLineage;
use quilt_uri::Namespace;
#[derive(Debug)]
pub struct Input {
pub namespace: Namespace,
pub count: Option<NonZeroUsize>,
}
pub struct Output {
namespace: Namespace,
removed: Vec<String>,
report: Option<RemovalReport>,
}
impl std::fmt::Display for Output {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.report {
Some(report) => write!(f, "{report}"),
None => write!(f, "No old revisions of {} to remove", self.namespace),
}
}
}
impl Render for Output {
fn to_json(&self) -> serde_json::Value {
let (revisions, objects, bytes, kept_for, kept_bytes) = match &self.report {
Some(report) => (
report.revisions,
report.objects,
report.bytes,
report.kept_for.as_ref().map(ToString::to_string),
report.kept_bytes,
),
None => (0, 0, 0, None, 0),
};
serde_json::json!({
"namespace": self.namespace.to_string(),
"removed": self.removed,
"revisions": revisions,
"objects": objects,
"bytes": bytes,
"kept_for": kept_for,
"kept_bytes": kept_bytes,
})
}
}
pub fn parse_count(value: &str) -> Result<NonZeroUsize, String> {
match value.parse::<usize>() {
Ok(count) => NonZeroUsize::new(count).ok_or_else(|| "must be at least 1".to_string()),
Err(_) => Err("must be a whole number of at least 1".to_string()),
}
}
pub async fn command(m: impl Commands, args: Input) -> Std {
Std::from_result(m.remove_revisions(args).await)
}
pub async fn model(
local_domain: &quilt_rs::LocalDomain,
Input { namespace, count }: Input,
) -> Result<Output, Error> {
let Some(package) = local_domain.get_installed_package(&namespace).await? else {
return Err(Error::NamespaceNotFound(namespace));
};
remove_old(&package, count).await
}
pub fn removable_oldest_first(
lineage: &PackageLineage,
history: &[HistoryEntry],
count: Option<NonZeroUsize>,
) -> Vec<String> {
let kept = quilt_rs::flow::protection(lineage, history);
history
.iter()
.rev()
.map(|entry| &entry.revision.hash)
.filter(|hash| !kept.contains_key(*hash))
.take(count.map_or(usize::MAX, NonZeroUsize::get))
.cloned()
.collect()
}
pub async fn remove_old<S: Storage + Clone + Sync, R: Remote>(
package: &InstalledPackage<S, R>,
count: Option<NonZeroUsize>,
) -> Result<Output, Error> {
let namespace = package.namespace.clone();
let lineage = package.lineage().await?;
let history = package.revision_history(&lineage).await?;
let removed = removable_oldest_first(&lineage, &history, count);
if removed.is_empty() {
return Ok(Output {
namespace,
removed,
report: None,
});
}
let hashes: BTreeSet<String> = removed.iter().cloned().collect();
match package.remove_revisions(&hashes).await {
Ok(report) => Ok(Output {
namespace,
removed,
report: Some(report),
}),
Err(quilt_rs::Error::PackageBusy(busy)) => Err(Error::PackageBusy(busy)),
Err(err) => Err(err.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use crate::cli::create;
use crate::cli::fixtures::old_revisions::held;
use crate::cli::fixtures::old_revisions::package as package_with_old_revisions;
use crate::cli::model::create_model_in_temp_dir;
#[test(tokio::test)]
async fn removes_every_removable_revision_without_a_count() -> Result<(), Error> {
let (package, _dirs) = package_with_old_revisions().await?;
let output = remove_old(&package, None).await?;
assert_eq!(output.removed, ["old-1", "old-2", "old-3"]);
assert_eq!(
output.to_string(),
"Removed 3 old revisions of test/history \u{b7} freed 211.9 kB"
);
assert_eq!(
output.to_json(),
serde_json::json!({
"namespace": "test/history",
"removed": ["old-1", "old-2", "old-3"],
"revisions": 3,
"objects": 2,
"bytes": 211_900,
"kept_for": null,
"kept_bytes": 0,
})
);
assert_eq!(held(&package).await?, ["current-rev", "local-rev"]);
Ok(())
}
#[test(tokio::test)]
async fn a_count_removes_only_the_oldest() -> Result<(), Error> {
let (package, _dirs) = package_with_old_revisions().await?;
let output = remove_old(&package, NonZeroUsize::new(2)).await?;
assert_eq!(output.removed, ["old-1", "old-2"]);
assert_eq!(
output.to_string(),
"Removed 2 old revisions of test/history \u{b7} freed 211.9 kB"
);
assert_eq!(held(&package).await?, ["current-rev", "local-rev", "old-3"]);
Ok(())
}
#[test(tokio::test)]
async fn a_count_past_the_removable_skips_the_kept() -> Result<(), Error> {
let (package, _dirs) = package_with_old_revisions().await?;
let output = remove_old(&package, NonZeroUsize::new(10)).await?;
assert_eq!(output.removed, ["old-1", "old-2", "old-3"]);
assert_eq!(held(&package).await?, ["current-rev", "local-rev"]);
let again = remove_old(&package, NonZeroUsize::new(1)).await?;
assert_eq!(
again.to_string(),
"No old revisions of test/history to remove"
);
Ok(())
}
#[test(tokio::test)]
async fn nothing_to_remove_succeeds_and_says_so() -> Result<(), Error> {
let (m, _temp_dir) = create_model_in_temp_dir().await?;
let namespace: Namespace = ("test", "local").into();
m.create(create::Input {
namespace: namespace.clone(),
source: None,
message: None,
})
.await?;
let output = m
.remove_revisions(Input {
namespace,
count: None,
})
.await?;
assert_eq!(
output.to_string(),
"No old revisions of test/local to remove"
);
assert_eq!(
output.to_json(),
serde_json::json!({
"namespace": "test/local",
"removed": [],
"revisions": 0,
"objects": 0,
"bytes": 0,
"kept_for": null,
"kept_bytes": 0,
})
);
Ok(())
}
#[test(tokio::test)]
async fn an_uninstalled_package_is_not_found() -> Result<(), Error> {
let (m, _temp_dir) = create_model_in_temp_dir().await?;
let Err(err) = m
.remove_revisions(Input {
namespace: ("test", "absent").into(),
count: None,
})
.await
else {
return Err(Error::Test("expected not found".to_string()));
};
assert_eq!(err.kind(), "namespace_not_found");
Ok(())
}
#[test]
fn a_count_is_at_least_one() {
assert_eq!(parse_count("3"), Ok(NonZeroUsize::new(3).expect("3")));
assert_eq!(parse_count("0"), Err("must be at least 1".to_string()));
assert_eq!(
parse_count("-1"),
Err("must be a whole number of at least 1".to_string())
);
}
}