adnv 1.5.0

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

use wax::walk::Entry as _;

use super::ParseOptions;
use crate::sources::VersionedSource;
use crate::{Error, Result};

pub async fn parse_dir(
    path: impl AsRef<Path>,
    opts: &ParseOptions,
) -> impl IntoIterator<Item = Result<VersionedSource>> {
    use futures::future::join_all;
    use itertools::Itertools;

    let pattern = if opts.recursive {
        "**/Chart.yaml"
    } else {
        "Chart.yaml"
    };

    let glob = wax::Glob::new(pattern).unwrap();
    let walker = glob
        .walk(path.as_ref())
        .filter_map(std::result::Result::ok)
        .filter(|entry| opts.hidden || !entry.to_candidate_path().as_ref().starts_with('.'))
        .map(parse_chart);

    join_all(walker).await.into_iter().flatten_ok()
}

async fn parse_chart(walk_entry: wax::walk::GlobEntry) -> Result<Vec<VersionedSource>> {
    let mut path = walk_entry.to_candidate_path().to_string();
    if path.is_empty() {
        path = "Chart.yaml".to_string();
    }

    let chart_content = tokio::fs::read_to_string(walk_entry.path())
        .await
        .map_err(|err| Error::io(err, "Failed to read helm chart"))?;
    let root_node = marked_yaml::parse_yaml(0, &chart_content)?;
    // A Chart.yaml whose root is a scalar or a sequence is not a chart; it is
    // a file in the scanned tree that happens to carry that name.
    let Some(root) = root_node.as_mapping() else {
        tracing::warn!(
            "Ignoring `{}`: a chart must be a YAML mapping",
            walk_entry.path().display()
        );

        return Ok(vec![]);
    };

    let Some(deps) = root.get_sequence("dependencies") else {
        return Ok(vec![]);
    };

    Ok(deps
        .iter()
        .filter_map(|entry| {
            use super::Location;
            use crate::sources::data::HelmSource;
            use crate::util::parse;

            let entry = entry.as_mapping()?;

            let version_node = entry.get_scalar("version")?;
            let version_start = version_node.span().start()?;
            let version_start_idx =
                parse::loc_to_index(&chart_content, version_start.line(), version_start.column())?;
            let mut version_end_idx = version_start_idx + version_node.len();
            // Fix case when string is quoted and marked-yaml doesn't correctly handle this.
            if chart_content.as_bytes().get(version_start_idx) == Some(&b'"') {
                version_end_idx += 2;
            }

            let name = entry.get_scalar("name")?.as_str();
            let version = version_node.as_str();
            let repository = entry
                .get_scalar("repository")
                .map(marked_yaml::types::MarkedScalarNode::as_str)?;

            let loc = Location::new(path.clone(), version_start.line(), version_start.column())
                .with_span(version_start_idx, version_end_idx);

            let source = HelmSource {
                repository: repository.to_string(),
                name: name.to_string(),
            };

            let versioned_source = VersionedSource::new(source)
                .with_current(version.to_owned())
                .with_required(version.to_owned());

            Some(versioned_source.with_location(loc))
        })
        .collect())
}

#[cfg(test)]
mod tests {
    use itertools::Itertools;

    use super::parse_dir;
    use crate::sources::data::{HelmSource, Source as SourceData};
    use crate::sources::parse::Location;
    use crate::sources::{VersionedSource, parse::ParseOptions};

    const TEST_DIRECTORY: &str = "tests/sources/parse";

    #[tokio::test]
    async fn parse_chart() {
        let (mut versions, errors): (Vec<_>, Vec<_>) = (parse_dir(
            TEST_DIRECTORY,
            &ParseOptions {
                recursive: false,
                hidden: true,
            },
        ))
        .await
        .into_iter()
        .partition_result();

        let expected = vec![
            VersionedSource::new(SourceData::Helm(HelmSource {
                repository: "https://helm.nginx.com/stable".to_string(),
                name: "nginx-ingress".to_string(),
            }))
            .with_current("0.17.0")
            .with_required("0.17.0")
            .with_location(Location::new("Chart.yaml", 6, 14).with_span(92, 100)),
        ];

        versions.sort_unstable();

        assert_eq!(versions, expected);
        assert!(errors.is_empty());
    }
}