adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
use chrono::{DateTime, Local};
use comfy_table::Table;
use serde::Deserialize;
use std::time::Duration;
use table_to_html::HtmlTable;

use super::VersionedSource;
use crate::ops::check::{Options, Result};

pub mod display;
pub mod element;
pub mod mail;
pub mod mattermost;

pub(crate) const DEFAULT_MESSAGE_TEMPLATE: &str = "\
There was {{ count }} new packages versions {%if since %}since {{ date }}{% else %}in the last {{ duration }}{% endif %}

{{ versions_table }}
";

pub(crate) fn should_skip_empty(send_empty: Option<bool>, sources: &[VersionedSource]) -> bool {
    !send_empty.unwrap_or(false) && sources.is_empty()
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub enum OutputKind {
    Mattermost(mattermost::OutputConfig),
    Element(element::OutputConfig),
    Mail(mail::OutputConfig),
}

#[derive(Clone, Copy)]
pub(crate) enum TableFormat {
    Html,
    ComfyTable,
}

pub(crate) fn render_message(
    opts: &Options,
    sources: &[VersionedSource],
    template: &str,
    table_format: TableFormat,
) -> Result<String> {
    use tera::{Context, Tera};

    let count: usize = sources.iter().map(|e| e.versions.len()).sum();

    let (duration, date) = compute_duration_and_date(opts.last, opts.since)?;

    let table = if sources.is_empty() {
        String::new()
    } else {
        match table_format {
            TableFormat::Html => setup_table_html(sources).to_string(),
            TableFormat::ComfyTable => {
                let mut table = Table::new();
                table
                    .load_style(comfy_table::presets::ASCII_MARKDOWN)
                    .set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth)
                    .set_width(120);

                setup_table(table, sources).to_string()
            }
        }
    };

    let mut tera = Tera::default();
    tera.add_raw_template("mattermost_template", template)?;

    let mut context = Context::new();
    context.insert("count", &count);
    context.insert("since", &opts.since);
    context.insert("last", &opts.last);
    context.insert("duration", &duration);
    context.insert("date", &date);
    context.insert("versions_table", &table);

    Ok(tera.render("mattermost_template", &context)?)
}

pub fn create_table_with_defaults(sources: &[VersionedSource]) -> Table {
    use comfy_table::{ContentArrangement, presets::UTF8_FULL};

    let mut table = Table::new();
    table
        .load_style(UTF8_FULL)
        .set_content_arrangement(ContentArrangement::DynamicFullWidth)
        .set_width(100);

    setup_table(table, sources)
}

pub fn setup_table(mut table: Table, sources: &[VersionedSource]) -> Table {
    table.set_header(vec!["Label", "Version", "Source"]);

    for source in sources {
        for version in &source.versions {
            table.add_row([
                source.source.label_or_source(),
                version.name(),
                source.source.source.as_str(),
            ]);
        }
    }

    table
}

/// `table_to_html` writes cell contents verbatim, and tag names come from the
/// remote registry, so escape before building the table: anyone able to publish
/// a tag on a watched source would otherwise inject markup into the
/// notification — a phishing link arriving on a channel the team trusts.
fn escape_html(value: &str) -> String {
    let mut buf = Vec::with_capacity(value.len());
    // Only fails if the writer fails, and a `Vec` does not.
    let _ = tera::escape_html(value, &mut buf);

    String::from_utf8(buf).unwrap_or_else(|_| value.to_owned())
}

pub fn setup_table_html(sources: &[VersionedSource]) -> HtmlTable {
    let mut table = vec![[
        "Label".to_owned(),
        "Version".to_owned(),
        "Source".to_owned(),
    ]];

    for source in sources {
        for version in &source.versions {
            table.push([
                escape_html(source.source.label_or_source()),
                escape_html(version.name()),
                escape_html(source.source.source.as_str()),
            ]);
        }
    }

    HtmlTable::new(table)
}

pub(crate) fn compute_duration_and_date(
    last: Option<Duration>,
    since: Option<DateTime<Local>>,
) -> Result<(String, String)> {
    use crate::util::date::{
        date_from_duration, date_to_formatted_string, duration_from_date,
        duration_to_formatted_string,
    };
    use chrono::Duration as chronoDuration;

    Ok(match (last, since) {
        (None, None) => (
            duration_to_formatted_string(Duration::new(86400, 0)),
            date_to_formatted_string(Local::now() - chronoDuration::days(1)),
        ),
        (None, Some(since)) => (
            duration_to_formatted_string(duration_from_date(since)?),
            date_to_formatted_string(since),
        ),
        (Some(last), _) => (
            duration_to_formatted_string(last),
            date_to_formatted_string(date_from_duration(last)?),
        ),
    })
}

#[cfg(test)]
mod tests {
    use super::setup_table_html;
    use crate::ops::check::{Source, VersionedSource};
    use crate::sources::list::Tag;

    /// Tag names come straight from the remote registry. `table_to_html` does
    /// not escape anything, and the message template is rendered without Tera's
    /// autoescaping — it is not named `.html`, and `versions_table` is meant to
    /// be raw HTML — so the escaping has to happen here.
    #[test]
    fn html_table_escapes_remote_tag_names() {
        let source: Source = "https://example.com/x".parse().unwrap();
        let versioned = VersionedSource {
            source,
            versions: vec![Tag::new_without_date(
                r#"v1.0<a href="https://evil.example/">Update now</a>"#,
            )],
        };

        let html = setup_table_html(&[versioned]).to_string();

        assert!(
            !html.contains("<a href="),
            "unescaped markup reached the notification: {html}"
        );
        assert!(html.contains("&lt;a href="), "not escaped at all: {html}");
    }
}