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