1use anyhow::{Context, Result, bail};
2use semver::Version;
3use serde::Deserialize;
4use std::collections::{BTreeMap, HashSet};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
9#[serde(deny_unknown_fields)]
10pub struct Config {
11 pub required_version: String,
12 pub repository: RepositoryConfig,
13 #[serde(default)]
14 pub hooks: HooksConfig,
15 #[serde(default)]
16 pub publishers: BTreeMap<String, PublisherConfig>,
17 #[serde(default)]
18 pub targets: Vec<TargetConfig>,
19}
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
22#[serde(deny_unknown_fields)]
23pub struct RepositoryConfig {
24 pub github: String,
25 pub branch: String,
26}
27
28#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
29#[serde(deny_unknown_fields)]
30pub struct HooksConfig {
31 #[serde(default)]
32 pub preflight: Vec<String>,
33}
34
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
36#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
37pub enum PublisherConfig {
38 GithubRelease {
39 title: String,
40 prerelease: bool,
41 },
42 GithubMaven {
43 settings: PathBuf,
44 server_id: String,
45 },
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
49#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
50pub enum TargetConfig {
51 DockerArchive {
52 name: String,
53 publisher: String,
54 #[serde(default)]
55 default: bool,
56 platform: String,
57 image: String,
58 asset: String,
59 build: Vec<String>,
60 local_check: Vec<String>,
61 },
62 MavenReactor {
63 name: String,
64 publisher: String,
65 #[serde(default)]
66 default: bool,
67 wrapper: PathBuf,
68 pom: PathBuf,
69 projects: Vec<String>,
70 also_make: bool,
71 remote_check: Vec<String>,
72 },
73}
74
75impl TargetConfig {
76 pub fn name(&self) -> &str {
77 match self {
78 Self::DockerArchive { name, .. } | Self::MavenReactor { name, .. } => name,
79 }
80 }
81
82 pub fn publisher(&self) -> &str {
83 match self {
84 Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
85 publisher
86 }
87 }
88 }
89
90 pub fn is_default(&self) -> bool {
91 match self {
92 Self::DockerArchive { default, .. } | Self::MavenReactor { default, .. } => *default,
93 }
94 }
95}
96
97impl Config {
98 pub fn load(path: &Path) -> Result<Self> {
99 let source = fs::read_to_string(path)
100 .with_context(|| format!("failed to read {}", path.display()))?;
101 Self::parse(&source).with_context(|| format!("invalid {}", path.display()))
102 }
103
104 pub fn parse(source: &str) -> Result<Self> {
105 let document: toml::Value = toml::from_str(source).context("invalid TOML")?;
106 let required = document
107 .get("required_version")
108 .and_then(toml::Value::as_str)
109 .context("`required_version` must be a semantic version string")?;
110 let required = Version::parse(required)
111 .with_context(|| format!("invalid required_version `{required}`"))?;
112 let current = Version::parse(env!("CARGO_PKG_VERSION"))
113 .expect("Cargo package version must be valid semantic versioning");
114 if current < required {
115 bail!(
116 "release.toml requires release-tool >= {required}; current version is {current}\n\
117 update the repository's release-tool dependency and Cargo.lock"
118 );
119 }
120
121 let config: Self = toml::from_str(source).context("config shape is invalid")?;
122 config.validate()?;
123 Ok(config)
124 }
125
126 fn validate(&self) -> Result<()> {
127 if self.repository.github.split('/').count() != 2
128 || self.repository.github.starts_with('/')
129 || self.repository.github.ends_with('/')
130 {
131 bail!(
132 "repository.github must use `owner/name`: {}",
133 self.repository.github
134 );
135 }
136 if self.repository.branch.trim().is_empty() {
137 bail!("repository.branch must not be empty");
138 }
139 validate_command("hooks.preflight", &self.hooks.preflight, true)?;
140
141 let mut names = HashSet::new();
142 for target in &self.targets {
143 if !names.insert(target.name()) {
144 bail!("duplicate target name `{}`", target.name());
145 }
146 }
147 for target in &self.targets {
148 let publisher = self.publishers.get(target.publisher()).with_context(|| {
149 format!(
150 "target `{}` references unknown publisher `{}`",
151 target.name(),
152 target.publisher()
153 )
154 })?;
155 let compatible = matches!(
156 (target, publisher),
157 (
158 TargetConfig::DockerArchive { .. },
159 PublisherConfig::GithubRelease { .. }
160 ) | (
161 TargetConfig::MavenReactor { .. },
162 PublisherConfig::GithubMaven { .. }
163 )
164 );
165 if !compatible {
166 bail!(
167 "target `{}` is incompatible with publisher `{}`",
168 target.name(),
169 target.publisher()
170 );
171 }
172 match target {
173 TargetConfig::DockerArchive {
174 build,
175 local_check,
176 asset,
177 ..
178 } => {
179 validate_command(&format!("targets.{}.build", target.name()), build, false)?;
180 validate_command(
181 &format!("targets.{}.local_check", target.name()),
182 local_check,
183 false,
184 )?;
185 validate_asset_name(target.name(), asset)?;
186 }
187 TargetConfig::MavenReactor {
188 projects,
189 remote_check,
190 ..
191 } => {
192 if projects.is_empty() {
193 bail!("target `{}` must select Maven projects", target.name());
194 }
195 validate_command(
196 &format!("targets.{}.remote_check", target.name()),
197 remote_check,
198 true,
199 )?;
200 }
201 }
202 }
203 Ok(())
204 }
205}
206
207fn validate_asset_name(target: &str, asset: &str) -> Result<()> {
208 let asset_path = Path::new(asset);
209 if asset_path.file_name().and_then(|name| name.to_str()) != Some(asset)
210 || asset == "."
211 || asset == ".."
212 {
213 bail!("target `{target}` asset must be one file name");
214 }
215 Ok(())
216}
217
218fn validate_command(name: &str, command: &[String], allow_empty: bool) -> Result<()> {
219 if command.is_empty() {
220 if allow_empty {
221 return Ok(());
222 }
223 bail!("{name} must not be empty");
224 }
225 if command.iter().any(|argument| argument.is_empty()) {
226 bail!("{name} arguments must not be empty");
227 }
228 Ok(())
229}