adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
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)
        })
    }

    /// Keeps a tag when `filter_fn` returns true, and **composes** with the
    /// filters already registered: once one of them decided to skip or to stop,
    /// a later filter cannot bring the tag back.
    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)
        })
    }

    /// Applies the filters to a complete, unordered list of tags — what the
    /// backends that fetch everything in one request return.
    ///
    /// `Break` is treated as `Skip` here: it means "past the date window", which
    /// only ends a listing when tags arrive newest first. A tag carrying no date
    /// at all, as the Terraform registry returns, is never dropped by `since`,
    /// since there is nothing to compare; name filters still apply to it.
    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()
    }

    /// The decision for a candidate tag. Single source of truth for the
    /// filtering semantics, and the seam the tests use.
    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)
            })
    }
}

/// Ceiling on how many pages a listing will walk. A hostile or broken registry
/// can otherwise paginate for ever, since the only exits are "no next page" and
/// a filter deciding to stop.
pub(crate) const MAX_PAGES: usize = 1_000;

/// Reports a listing that would not end.
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,
        // These three fetch the whole list in one request, so they cannot stop
        // early and are filtered here instead.
        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;

    /// `--include` and `--exclude` must compose. They used to overwrite each
    /// other: both filters returned `Pick`/`Skip` without looking at the
    /// decision already taken, so whichever was registered last won on its own.
    #[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
        );
    }
}