use std::fmt;
use chrono::{DateTime, Utc};
use smart_default::SmartDefault;
use super::Source;
use crate::Result;
use crate::util::iter::FilterWhileAction;
mod docker;
mod git;
mod helm;
mod npm;
mod terraform;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Tag {
name: String,
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl Tag {
pub fn new(
name: impl Into<String>,
created_at: impl Into<chrono::DateTime<chrono::Utc>>,
) -> Self {
Self {
name: name.into(),
created_at: Some(created_at.into()),
}
}
pub fn new_without_date(name: impl Into<String>) -> Self {
Self {
name: name.into(),
created_at: None,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn created_at(&self) -> Option<&chrono::DateTime<chrono::Utc>> {
self.created_at.as_ref()
}
pub fn with_created_at(self, created_at: impl Into<chrono::DateTime<chrono::Utc>>) -> Self {
Self {
created_at: Some(created_at.into()),
..self
}
}
}
impl PartialEq for Tag {
fn eq(&self, other: &Self) -> bool {
self.name() == other.name()
}
}
impl Eq for Tag {}
impl PartialOrd for Tag {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Tag {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.name.cmp(&other.name)
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{name}", name = self.name)?;
if let Some(created_at) = self.created_at {
write!(f, " ({created_at})")?;
}
Ok(())
}
}
#[derive(SmartDefault)]
pub struct ListOptions<'a> {
since: Option<DateTime<Utc>>,
#[allow(clippy::type_complexity)]
filter_fn: Option<Box<dyn Fn(&str, &DateTime<Utc>) -> FilterWhileAction + 'a>>,
}
impl<'a> ListOptions<'a> {
#[inline]
pub fn new() -> Self {
Self::default()
}
pub fn cloned<'b>(&'a self) -> ListOptions<'b>
where
'a: 'b,
{
let mut options = Self {
since: self.since,
..Default::default()
};
if let Some(filter_fn) = &self.filter_fn {
options = options.filter(filter_fn);
}
options
}
pub fn since(mut self, since: DateTime<Utc>) -> Self {
self.since = Some(since);
self
}
pub fn filter(
mut self,
filter_fn: impl Fn(&str, &DateTime<Utc>) -> FilterWhileAction + 'a,
) -> Self {
self.filter_fn = Some(Box::new(filter_fn));
self
}
pub fn add_filter(
mut self,
filter_fn: impl Fn(FilterWhileAction, &str, &DateTime<Utc>) -> FilterWhileAction + 'a,
) -> Self {
let current_filter = self.filter_fn.take();
self.filter(move |name, date| {
let lhs = current_filter
.as_ref()
.map(|filter_fn| filter_fn(name, date))
.unwrap_or_default();
filter_fn(lhs, name, date)
})
}
pub fn pick_when_true(self, filter_fn: impl Fn(&str, &DateTime<Utc>) -> bool + 'a) -> Self {
self.add_filter(move |action, name, created_at| {
let decision = if filter_fn(name, created_at) {
FilterWhileAction::Pick
} else {
FilterWhileAction::Skip
};
action.or(decision)
})
}
pub(crate) fn filter_tags(&self, tags: Vec<Tag>) -> Vec<Tag> {
tags.into_iter()
.filter(|tag| {
let created_at = tag.created_at().copied().unwrap_or_else(Utc::now);
self.consider(tag.name(), &created_at) != FilterWhileAction::Skip
})
.collect()
}
pub(crate) fn consider(&self, name: &str, created_at: &DateTime<Utc>) -> FilterWhileAction {
if let Some(since) = self.since
&& *created_at < since
{
return FilterWhileAction::Break;
}
self.filter_fn
.as_ref()
.map_or(FilterWhileAction::Pick, |filter_fn| {
filter_fn(name, created_at)
})
}
}
pub(crate) const MAX_PAGES: usize = 1_000;
pub(crate) fn too_many_pages(registry: &str) -> crate::Error {
crate::Error::io(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{registry} returned more than {MAX_PAGES} pages of tags"),
),
"Refusing to keep paginating",
)
}
pub async fn list_tags(source: &Source, options: &ListOptions<'_>) -> Result<Vec<Tag>> {
use crate::sources::data::Source as SourceBase;
match &source.data {
SourceBase::Docker(source) => docker::list_tags(source, options).await,
SourceBase::Git(source) => git::list_tags(source, options).await,
SourceBase::Helm(source) => Ok(options.filter_tags(helm::list_tags(source).await?)),
SourceBase::Npm(source) => Ok(options.filter_tags(npm::list_tags(source).await?)),
SourceBase::Terraform(source) => {
Ok(options.filter_tags(terraform::list_tags(source).await?))
}
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use super::ListOptions;
use crate::util::iter::FilterWhileAction;
#[test]
fn include_and_exclude_compose() {
let include = regex::Regex::new(r"^v1\.").unwrap();
let exclude = regex::Regex::new("-rc").unwrap();
let options = ListOptions::new()
.pick_when_true(move |name, _| include.is_match(name))
.pick_when_true(move |name, _| !exclude.is_match(name));
let date = Utc.timestamp_opt(0, 0).unwrap();
for (name, expected) in [
("v1.2.0", FilterWhileAction::Pick),
("v1.3.0-rc1", FilterWhileAction::Skip),
("v2.0.0", FilterWhileAction::Skip),
("v2.0.0-rc1", FilterWhileAction::Skip),
] {
assert_eq!(options.consider(name, &date), expected, "tag: {name}");
}
}
#[test]
fn since_stops_the_pagination() {
let options = ListOptions::new().since(Utc.timestamp_opt(1_000, 0).unwrap());
assert_eq!(
options.consider("v1.0.0", &Utc.timestamp_opt(999, 0).unwrap()),
FilterWhileAction::Break
);
assert_eq!(
options.consider("v1.0.0", &Utc.timestamp_opt(1_001, 0).unwrap()),
FilterWhileAction::Pick
);
}
}