adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;

use chrono::{DateTime, Local};
use educe::Educe;
use regex::Regex;
use serde::Deserialize;

use self::output::OutputKind;
use self::output::{display, element, mail, mattermost};
use crate::sources::{
    data,
    list::{self, ListOptions},
};
use crate::util::serde::MapOrString;
use crate::{Error, Result, Results};

pub mod output;
mod process;

#[derive(Debug, Clone)]
pub struct Options {
    pub last: Option<Duration>,
    pub since: Option<DateTime<Local>>,
    pub include: Option<Regex>,
    pub exclude: Option<Regex>,
    pub outputs: Vec<String>,
}

#[allow(dead_code)]
impl Options {
    fn last_or_default(&self) -> Duration {
        self.last.unwrap_or_else(|| Duration::from_hours(24))
    }
}

pub async fn run(
    options: Options,
    sources: &[Source],
    outputs: HashMap<String, OutputKind>,
) -> Result<()> {
    if sources.is_empty() {
        tracing::error!("No sources provided");
        return Ok(());
    }

    let mut list_options =
        ListOptions::default().since(process::compute_since_date(&options).into());

    if let Some(regex) = options.include.as_ref() {
        list_options = list_options.pick_when_true(move |name, _| regex.is_match(name));
    }
    if let Some(regex) = options.exclude.as_ref() {
        list_options = list_options.pick_when_true(move |name, _| !regex.is_match(name));
    }

    let results: Results<VersionedSource> = futures::future::join_all(
        sources
            .iter()
            .map(|source| fetch_source(source, &list_options)),
    )
    .await
    .into();

    // Report what failed before anything else: a check that cannot reach a
    // registry must not look like a check that found nothing.
    let (fetched, errors) = results.split();
    let failed = errors.len();
    for err in errors {
        tracing::error!("{:?}", miette::Report::new(err));
    }

    let sources = process::process_result(&fetched, &options);

    if options.outputs.is_empty() {
        display::display(&sources);
    } else {
        for output_name in &options.outputs {
            if let Some(output) = outputs.get(output_name) {
                match output {
                    OutputKind::Mattermost(config) => {
                        mattermost::output_mattermost(&options, &sources, config).await?;
                    }
                    OutputKind::Element(config) => {
                        element::output_element(&options, &sources, config).await?;
                    }
                    OutputKind::Mail(config) => {
                        mail::output_mail(&options, &sources, config).await?;
                    }
                }
            } else {
                tracing::warn!("Unknown output: {output_name}");
            }
        }
    }

    // Notifications for the sources that did work have been sent; now fail, so
    // that a scheduled check does not stay green while it stopped checking.
    if failed > 0 {
        return Err(Error::SourcesFailed { count: failed });
    }

    Ok(())
}

#[serde_with::serde_as]
#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
pub struct Config {
    // Both default: a config may declare only its outputs, and `run` already
    // reports an empty source list rather than relying on deserialization.
    #[serde_as(as = "Vec<MapOrString>")]
    #[serde(default)]
    pub sources: Vec<Source>,
    #[serde(default)]
    pub outputs: HashMap<String, OutputKind>,
}

#[serde_with::serde_as]
#[derive(Educe, Debug, Clone, Deserialize)]
#[educe(PartialEq)]
pub struct Source {
    pub label: Option<String>,
    pub source: url::Url,
    #[educe(PartialEq(ignore))]
    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
    pub include: Option<Regex>,
    #[educe(PartialEq(ignore))]
    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
    pub exclude: Option<Regex>,
}

impl Source {
    pub fn label_or_source(&self) -> &str {
        self.label.as_deref().unwrap_or(self.source.as_str())
    }

    pub fn parse_source_data(&self) -> Result<data::Source> {
        data::Source::parse_url(&self.source)
    }
}

impl FromStr for Source {
    type Err = url::ParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (label, source) = match s.split_once("::") {
            Some((label, source)) => (Some(label), source),
            None => (None, s),
        };

        Ok(Source {
            label: label.map(ToOwned::to_owned),
            source: source.parse()?,
            include: None,
            exclude: None,
        })
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct VersionedSource {
    source: Source,
    versions: Vec<list::Tag>,
}

async fn fetch_source(source: &Source, options: &ListOptions<'_>) -> Result<VersionedSource> {
    let data = source.parse_source_data()?;

    let mut options = options.cloned();

    if let Some(regex) = source.include.as_ref() {
        options = options.pick_when_true(move |name, _| regex.is_match(name));
    }
    if let Some(regex) = source.exclude.as_ref() {
        options = options.pick_when_true(move |name, _| !regex.is_match(name));
    }

    let tags = list::list_tags(&data.into(), &options).await?;

    Ok(VersionedSource {
        source: source.clone(),
        versions: tags,
    })
}