adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
use smart_default::SmartDefault;

use super::list::Tag;
use super::{Version, VersionedSource};
use crate::{Error, Result, error::ErrorWithData};

mod version;

#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedSource {
    pub source: VersionedSource,
    pub wanted: Option<Version>,
    pub latest: Version,
}

impl ResolvedSource {
    pub fn new(source: VersionedSource, latest: impl Into<Version>) -> Self {
        Self {
            source,
            wanted: None,
            latest: latest.into(),
        }
    }

    pub fn with_wanted(self, wanted: impl Into<Version>) -> Self {
        Self {
            wanted: Some(wanted.into()),
            ..self
        }
    }
}

#[derive(Debug, Clone, SmartDefault)]
pub struct ResolveOptions {}

pub fn resolve_tags(
    source: &VersionedSource,
    tags: &[Tag],
    _options: &ResolveOptions,
) -> Result<Option<ResolvedSource>, ErrorWithData<VersionedSource>> {
    use version::{Version, VersionReq};

    let versions: Vec<_> = tags
        .iter()
        .filter_map(|tag| {
            let version = Version::parse(&source.source, tag.name()).ok()?;
            Some(Tagged(tag, version))
        })
        .collect();
    // Do not rely on the order tags arrive in: every paginated backend returns
    // the newest first, the raw git backend sorts tag names as ASCII and npm
    // iterates a `HashMap`. `Version` is `Ord`, so ask for the maximum.
    let Tagged(latest_tag, latest) = versions
        .iter()
        .max_by(|Tagged(_, a), Tagged(_, b)| a.cmp(b))
        .cloned()
        .ok_or_else(|| Error::NoVersionAvailable.with_data(source.clone()))?;

    let req = if let Some(req) = &source.required {
        VersionReq::parse(&source.source, req).map_err(|err| err.with_data(source.clone()))?
    } else {
        return Ok(Some(ResolvedSource {
            source: source.clone(),
            wanted: None,
            latest: latest_tag.name().into(),
        }));
    };

    let wanted = versions
        .iter()
        .filter(|Tagged(_, version)| req.matches(version))
        .max_by(|Tagged(_, a), Tagged(_, b)| a.cmp(b))
        .cloned();

    if req.matches(&latest) {
        return Ok(None);
    }

    Ok(Some(ResolvedSource {
        source: source.clone(),
        wanted: wanted.map(|Tagged(tag, _)| tag.name().into()),
        latest: latest_tag.name().into(),
    }))
}

#[derive(Debug, Clone)]
struct Tagged<'a, T>(&'a Tag, T);

#[cfg(test)]
mod tests {
    #[test]
    fn test_simple() {
        use crate::sources::{VersionedSource, data};

        let source = VersionedSource::new(data::TerraformSource::new_provider("gitlabhq/gitlab"))
            .with_current("~> 1.2.1")
            .with_required("~> 1.2.1");
        let tags = tags![
            "1.0.0", "1.2.0", "1.2.55", "1.2.8", "1.3.0", "1.3.9", "2.0.0"
        ];

        // `~> 1.2.1` allows up to 1.3.0 exclusive, so the wanted version is the
        // highest match: 1.2.55, not 1.2.8. The fixture is ordered as ASCII,
        // which is why picking by position used to answer 1.2.8.
        let expected = super::ResolvedSource {
            source: source.clone(),
            wanted: Some("1.2.55".into()),
            latest: "2.0.0".into(),
        };

        let result = super::resolve_tags(&source, &tags, &super::ResolveOptions::default());

        assert_eq!(result.unwrap(), Some(expected));
    }

    #[test]
    fn test_operator_up_to_date() {
        use crate::sources::{VersionedSource, data};

        let source = VersionedSource::new(data::TerraformSource::new_provider("gitlabhq/gitlab"))
            .with_current(">= 1.0.0")
            .with_required(">= 1.0.0");
        let tags = tags!["0.1.0", "0.2.4", "1.0.0"];

        let result = super::resolve_tags(&source, &tags, &super::ResolveOptions::default());

        assert_matches!(result, Ok(None));
    }

    #[test]
    fn test_suffix_up_to_date() {
        use crate::sources::{VersionedSource, data};

        let source = VersionedSource::new(data::TerraformSource::new_provider("gitlabhq/gitlab"))
            .with_current("= 1.0.0-beta")
            .with_required("= 1.0.0-beta");
        let tags = tags!["0.1.0", "0.2.4", "1.0.0-beta"];

        let result = super::resolve_tags(&source, &tags, &super::ResolveOptions::default());

        assert_matches!(result, Ok(None));
    }

    /// Every paginated backend returns the newest tag first — `fetch-tags.graphql`
    /// asks for `direction: DESC`, and the listing loops break on `created_at <
    /// since`, which only holds for a descending list. Resolution must therefore
    /// not depend on the order in which tags arrive.
    #[test]
    fn test_descending_order() {
        use crate::sources::{VersionedSource, data};

        let source = VersionedSource::new(data::TerraformSource::new_provider("gitlabhq/gitlab"))
            .with_current("~> 1.2.1")
            .with_required("~> 1.2.1");
        let tags = tags![
            "2.0.0", "1.3.9", "1.3.0", "1.2.8", "1.2.55", "1.2.0", "1.0.0"
        ];

        let expected = super::ResolvedSource {
            source: source.clone(),
            wanted: Some("1.2.55".into()),
            latest: "2.0.0".into(),
        };

        let result = super::resolve_tags(&source, &tags, &super::ResolveOptions::default());

        assert_eq!(result.unwrap(), Some(expected));
    }

    /// npm iterates a `HashMap`, so its order is not even stable between runs.
    #[test]
    fn test_arbitrary_order() {
        use crate::sources::{Version, VersionedSource, data};

        let source = VersionedSource::new(data::TerraformSource::new_provider("gitlabhq/gitlab"))
            .with_current("~> 1.2.1")
            .with_required("~> 1.2.1");
        let tags = tags![
            "1.2.55", "2.0.0", "1.0.0", "1.3.9", "1.2.0", "1.2.8", "1.3.0"
        ];

        let result = super::resolve_tags(&source, &tags, &super::ResolveOptions::default());

        let resolved = result.unwrap().expect("an update is available");
        assert_eq!(resolved.latest, Version::from("2.0.0"));
        assert_eq!(resolved.wanted, Some(Version::from("1.2.55")));
    }

    #[test]
    fn test_different_requirement() {
        use crate::sources::{VersionedSource, data};

        let source = VersionedSource::new(data::TerraformSource::new_provider("gitlabhq/gitlab"))
            .with_current("!= 0.2.4")
            .with_required("!= 0.2.4");
        let tags = tags!["0.1.0", "0.2.4", "1.0.0"];

        let result = super::resolve_tags(&source, &tags, &super::ResolveOptions::default());

        assert_matches!(result, Ok(None));
    }
}