release-tool 0.2.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use anyhow::{Context, Result, bail};
use semver::Version;
use serde::Deserialize;
use std::collections::{BTreeMap, 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,
        platform: String,
        image: String,
        asset: String,
        build: Vec<String>,
        local_check: Vec<String>,
    },
    MavenReactor {
        name: String,
        publisher: String,
        #[serde(default)]
        default: bool,
        wrapper: PathBuf,
        pom: PathBuf,
        projects: Vec<String>,
        also_make: bool,
        remote_check: Vec<String>,
    },
}

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

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

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

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)
    }

    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());
            }
        }
        for target in &self.targets {
            let publisher = self.publishers.get(target.publisher()).with_context(|| {
                format!(
                    "target `{}` references unknown publisher `{}`",
                    target.name(),
                    target.publisher()
                )
            })?;
            let compatible = matches!(
                (target, publisher),
                (
                    TargetConfig::DockerArchive { .. },
                    PublisherConfig::GithubRelease { .. }
                ) | (
                    TargetConfig::MavenReactor { .. },
                    PublisherConfig::GithubMaven { .. }
                )
            );
            if !compatible {
                bail!(
                    "target `{}` is incompatible with publisher `{}`",
                    target.name(),
                    target.publisher()
                );
            }
            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,
                    )?;
                }
            }
        }
        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(())
}