release-tool 0.3.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use anyhow::{Context, Result, bail};
use semver::Version;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub required_version: String,
    pub repository: RepositoryConfig,
    #[serde(default)]
    pub hooks: HooksConfig,
    #[serde(default)]
    pub publishers: BTreeMap<String, PublisherConfig>,
    #[serde(default)]
    pub targets: Vec<TargetConfig>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RepositoryConfig {
    pub github: String,
    pub branch: String,
}

#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct HooksConfig {
    #[serde(default)]
    pub preflight: Vec<String>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PublisherConfig {
    GithubRelease {
        title: String,
        prerelease: bool,
    },
    GithubMaven {
        settings: PathBuf,
        server_id: String,
    },
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TargetConfig {
    DockerArchive {
        name: String,
        publisher: String,
        #[serde(default)]
        default: bool,
        #[serde(default)]
        depends_on: Vec<String>,
        platform: String,
        image: String,
        asset: String,
        build: Vec<String>,
        local_check: Vec<String>,
    },
    MavenReactor {
        name: String,
        publisher: String,
        #[serde(default)]
        default: bool,
        #[serde(default)]
        depends_on: Vec<String>,
        wrapper: PathBuf,
        pom: PathBuf,
        projects: Vec<String>,
        also_make: bool,
        remote_check: Vec<String>,
    },
    OciImage {
        name: String,
        #[serde(default)]
        default: bool,
        #[serde(default)]
        depends_on: Vec<String>,
        image: String,
        platform: String,
        reuse_check: Vec<String>,
        build: Vec<String>,
    },
}

impl TargetConfig {
    pub fn name(&self) -> &str {
        match self {
            Self::DockerArchive { name, .. }
            | Self::MavenReactor { name, .. }
            | Self::OciImage { name, .. } => name,
        }
    }

    pub fn publisher(&self) -> &str {
        match self {
            Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
                publisher
            }
            Self::OciImage { .. } => crate::oci::OCI_REGISTRY_PUBLISHER,
        }
    }

    pub fn configured_publisher(&self) -> Option<&str> {
        match self {
            Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
                Some(publisher)
            }
            Self::OciImage { .. } => None,
        }
    }

    pub fn is_default(&self) -> bool {
        match self {
            Self::DockerArchive { default, .. }
            | Self::MavenReactor { default, .. }
            | Self::OciImage { default, .. } => *default,
        }
    }

    pub fn dependencies(&self) -> &[String] {
        match self {
            Self::DockerArchive { depends_on, .. }
            | Self::MavenReactor { depends_on, .. }
            | Self::OciImage { depends_on, .. } => depends_on,
        }
    }

    pub fn is_oci_image(&self) -> bool {
        matches!(self, Self::OciImage { .. })
    }
}

impl Config {
    pub fn load(path: &Path) -> Result<Self> {
        let source = fs::read_to_string(path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        Self::parse(&source).with_context(|| format!("invalid {}", path.display()))
    }

    pub fn parse(source: &str) -> Result<Self> {
        let document: toml::Value = toml::from_str(source).context("invalid TOML")?;
        let required = document
            .get("required_version")
            .and_then(toml::Value::as_str)
            .context("`required_version` must be a semantic version string")?;
        let required = Version::parse(required)
            .with_context(|| format!("invalid required_version `{required}`"))?;
        let current = Version::parse(env!("CARGO_PKG_VERSION"))
            .expect("Cargo package version must be valid semantic versioning");
        if current < required {
            bail!(
                "release.toml requires release-tool >= {required}; current version is {current}\n\
                 update the repository's release-tool dependency and Cargo.lock"
            );
        }

        let config: Self = toml::from_str(source).context("config shape is invalid")?;
        config.validate()?;
        Ok(config)
    }

    pub(crate) fn targets_in_dependency_order(&self) -> Result<Vec<&TargetConfig>> {
        let by_name = self
            .targets
            .iter()
            .map(|target| (target.name(), target))
            .collect::<BTreeMap<_, _>>();
        let mut indegree = by_name
            .keys()
            .map(|name| (*name, by_name[name].dependencies().len()))
            .collect::<BTreeMap<_, _>>();
        let mut downstream = BTreeMap::<&str, Vec<&str>>::new();
        for target in &self.targets {
            for dependency in target.dependencies() {
                downstream
                    .entry(dependency)
                    .or_default()
                    .push(target.name());
            }
        }
        for targets in downstream.values_mut() {
            targets.sort_unstable();
        }
        let mut ready = indegree
            .iter()
            .filter_map(|(name, count)| (*count == 0).then_some(*name))
            .collect::<BTreeSet<_>>();
        let mut ordered = Vec::with_capacity(self.targets.len());
        while let Some(name) = ready.pop_first() {
            ordered.push(by_name[name]);
            for dependent in downstream.get(name).into_iter().flatten() {
                let count = indegree
                    .get_mut(dependent)
                    .expect("validated dependent target must have indegree");
                *count -= 1;
                if *count == 0 {
                    ready.insert(dependent);
                }
            }
        }
        if ordered.len() != self.targets.len() {
            let cycle = indegree
                .into_iter()
                .filter_map(|(name, count)| (count > 0).then_some(name))
                .collect::<Vec<_>>()
                .join(", ");
            bail!("target dependency cycle includes: {cycle}");
        }
        Ok(ordered)
    }

    fn validate(&self) -> Result<()> {
        if self.repository.github.split('/').count() != 2
            || self.repository.github.starts_with('/')
            || self.repository.github.ends_with('/')
        {
            bail!(
                "repository.github must use `owner/name`: {}",
                self.repository.github
            );
        }
        if self.repository.branch.trim().is_empty() {
            bail!("repository.branch must not be empty");
        }
        validate_command("hooks.preflight", &self.hooks.preflight, true)?;

        let mut names = HashSet::new();
        for target in &self.targets {
            if !names.insert(target.name()) {
                bail!("duplicate target name `{}`", target.name());
            }
        }
        self.validate_dependencies()?;
        self.targets_in_dependency_order()?;

        for target in &self.targets {
            if let Some(publisher_name) = target.configured_publisher() {
                let publisher = self.publishers.get(publisher_name).with_context(|| {
                    format!(
                        "target `{}` references unknown publisher `{publisher_name}`",
                        target.name()
                    )
                })?;
                let compatible = matches!(
                    (target, publisher),
                    (
                        TargetConfig::DockerArchive { .. },
                        PublisherConfig::GithubRelease { .. }
                    ) | (
                        TargetConfig::MavenReactor { .. },
                        PublisherConfig::GithubMaven { .. }
                    )
                );
                if !compatible {
                    bail!(
                        "target `{}` is incompatible with publisher `{publisher_name}`",
                        target.name()
                    );
                }
            }
            match target {
                TargetConfig::DockerArchive {
                    build,
                    local_check,
                    asset,
                    ..
                } => {
                    validate_command(&format!("targets.{}.build", target.name()), build, false)?;
                    validate_command(
                        &format!("targets.{}.local_check", target.name()),
                        local_check,
                        false,
                    )?;
                    validate_asset_name(target.name(), asset)?;
                }
                TargetConfig::MavenReactor {
                    projects,
                    remote_check,
                    ..
                } => {
                    if projects.is_empty() {
                        bail!("target `{}` must select Maven projects", target.name());
                    }
                    validate_command(
                        &format!("targets.{}.remote_check", target.name()),
                        remote_check,
                        true,
                    )?;
                }
                TargetConfig::OciImage {
                    image,
                    platform,
                    reuse_check,
                    build,
                    ..
                } => {
                    crate::oci::validate_image_template(image, platform)?;
                    validate_command(
                        &format!("targets.{}.reuse_check", target.name()),
                        reuse_check,
                        false,
                    )?;
                    validate_command(&format!("targets.{}.build", target.name()), build, false)?;
                }
            }
        }
        Ok(())
    }

    fn validate_dependencies(&self) -> Result<()> {
        let by_name = self
            .targets
            .iter()
            .map(|target| (target.name(), target))
            .collect::<BTreeMap<_, _>>();
        for target in &self.targets {
            let mut unique = HashSet::new();
            for dependency in target.dependencies() {
                if dependency == target.name() {
                    bail!("target `{}` cannot depend on itself", target.name());
                }
                if !unique.insert(dependency) {
                    bail!(
                        "target `{}` contains duplicate dependency `{dependency}`",
                        target.name()
                    );
                }
                let dependency_target = by_name.get(dependency.as_str()).with_context(|| {
                    format!(
                        "target `{}` references unknown dependency `{dependency}`",
                        target.name()
                    )
                })?;
                if !target.is_oci_image() || !dependency_target.is_oci_image() {
                    bail!(
                        "target `{}` cannot consume dependency `{dependency}`; v1 only supports oci_image -> oci_image dependencies",
                        target.name()
                    );
                }
            }
        }
        Ok(())
    }
}

fn validate_asset_name(target: &str, asset: &str) -> Result<()> {
    let asset_path = Path::new(asset);
    if asset_path.file_name().and_then(|name| name.to_str()) != Some(asset)
        || asset == "."
        || asset == ".."
    {
        bail!("target `{target}` asset must be one file name");
    }
    Ok(())
}

fn validate_command(name: &str, command: &[String], allow_empty: bool) -> Result<()> {
    if command.is_empty() {
        if allow_empty {
            return Ok(());
        }
        bail!("{name} must not be empty");
    }
    if command.iter().any(|argument| argument.is_empty()) {
        bail!("{name} arguments must not be empty");
    }
    Ok(())
}