1use anyhow::{Context, Result, bail};
2use semver::Version;
3use serde::Deserialize;
4use std::collections::{BTreeMap, BTreeSet, 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 #[serde(default)]
57 depends_on: Vec<String>,
58 platform: String,
59 image: String,
60 asset: String,
61 build: Vec<String>,
62 local_check: Vec<String>,
63 },
64 MavenReactor {
65 name: String,
66 publisher: String,
67 #[serde(default)]
68 default: bool,
69 #[serde(default)]
70 depends_on: Vec<String>,
71 wrapper: PathBuf,
72 pom: PathBuf,
73 projects: Vec<String>,
74 also_make: bool,
75 remote_check: Vec<String>,
76 },
77 OciImage {
78 name: String,
79 #[serde(default)]
80 default: bool,
81 #[serde(default)]
82 depends_on: Vec<String>,
83 image: String,
84 platform: String,
85 reuse_check: Vec<String>,
86 build: Vec<String>,
87 },
88}
89
90impl TargetConfig {
91 pub fn name(&self) -> &str {
92 match self {
93 Self::DockerArchive { name, .. }
94 | Self::MavenReactor { name, .. }
95 | Self::OciImage { name, .. } => name,
96 }
97 }
98
99 pub fn publisher(&self) -> &str {
100 match self {
101 Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
102 publisher
103 }
104 Self::OciImage { .. } => crate::oci::OCI_REGISTRY_PUBLISHER,
105 }
106 }
107
108 pub fn configured_publisher(&self) -> Option<&str> {
109 match self {
110 Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
111 Some(publisher)
112 }
113 Self::OciImage { .. } => None,
114 }
115 }
116
117 pub fn is_default(&self) -> bool {
118 match self {
119 Self::DockerArchive { default, .. }
120 | Self::MavenReactor { default, .. }
121 | Self::OciImage { default, .. } => *default,
122 }
123 }
124
125 pub fn dependencies(&self) -> &[String] {
126 match self {
127 Self::DockerArchive { depends_on, .. }
128 | Self::MavenReactor { depends_on, .. }
129 | Self::OciImage { depends_on, .. } => depends_on,
130 }
131 }
132
133 pub fn is_oci_image(&self) -> bool {
134 matches!(self, Self::OciImage { .. })
135 }
136}
137
138impl Config {
139 pub fn load(path: &Path) -> Result<Self> {
140 let source = fs::read_to_string(path)
141 .with_context(|| format!("failed to read {}", path.display()))?;
142 Self::parse(&source).with_context(|| format!("invalid {}", path.display()))
143 }
144
145 pub fn parse(source: &str) -> Result<Self> {
146 let document: toml::Value = toml::from_str(source).context("invalid TOML")?;
147 let required = document
148 .get("required_version")
149 .and_then(toml::Value::as_str)
150 .context("`required_version` must be a semantic version string")?;
151 let required = Version::parse(required)
152 .with_context(|| format!("invalid required_version `{required}`"))?;
153 let current = Version::parse(env!("CARGO_PKG_VERSION"))
154 .expect("Cargo package version must be valid semantic versioning");
155 if current < required {
156 bail!(
157 "release.toml requires release-tool >= {required}; current version is {current}\n\
158 update the repository's release-tool dependency and Cargo.lock"
159 );
160 }
161
162 let config: Self = toml::from_str(source).context("config shape is invalid")?;
163 config.validate()?;
164 Ok(config)
165 }
166
167 pub(crate) fn targets_in_dependency_order(&self) -> Result<Vec<&TargetConfig>> {
168 let by_name = self
169 .targets
170 .iter()
171 .map(|target| (target.name(), target))
172 .collect::<BTreeMap<_, _>>();
173 let mut indegree = by_name
174 .keys()
175 .map(|name| (*name, by_name[name].dependencies().len()))
176 .collect::<BTreeMap<_, _>>();
177 let mut downstream = BTreeMap::<&str, Vec<&str>>::new();
178 for target in &self.targets {
179 for dependency in target.dependencies() {
180 downstream
181 .entry(dependency)
182 .or_default()
183 .push(target.name());
184 }
185 }
186 for targets in downstream.values_mut() {
187 targets.sort_unstable();
188 }
189 let mut ready = indegree
190 .iter()
191 .filter_map(|(name, count)| (*count == 0).then_some(*name))
192 .collect::<BTreeSet<_>>();
193 let mut ordered = Vec::with_capacity(self.targets.len());
194 while let Some(name) = ready.pop_first() {
195 ordered.push(by_name[name]);
196 for dependent in downstream.get(name).into_iter().flatten() {
197 let count = indegree
198 .get_mut(dependent)
199 .expect("validated dependent target must have indegree");
200 *count -= 1;
201 if *count == 0 {
202 ready.insert(dependent);
203 }
204 }
205 }
206 if ordered.len() != self.targets.len() {
207 let cycle = indegree
208 .into_iter()
209 .filter_map(|(name, count)| (count > 0).then_some(name))
210 .collect::<Vec<_>>()
211 .join(", ");
212 bail!("target dependency cycle includes: {cycle}");
213 }
214 Ok(ordered)
215 }
216
217 fn validate(&self) -> Result<()> {
218 if self.repository.github.split('/').count() != 2
219 || self.repository.github.starts_with('/')
220 || self.repository.github.ends_with('/')
221 {
222 bail!(
223 "repository.github must use `owner/name`: {}",
224 self.repository.github
225 );
226 }
227 if self.repository.branch.trim().is_empty() {
228 bail!("repository.branch must not be empty");
229 }
230 validate_command("hooks.preflight", &self.hooks.preflight, true)?;
231
232 let mut names = HashSet::new();
233 for target in &self.targets {
234 if !names.insert(target.name()) {
235 bail!("duplicate target name `{}`", target.name());
236 }
237 }
238 self.validate_dependencies()?;
239 self.targets_in_dependency_order()?;
240
241 for target in &self.targets {
242 if let Some(publisher_name) = target.configured_publisher() {
243 let publisher = self.publishers.get(publisher_name).with_context(|| {
244 format!(
245 "target `{}` references unknown publisher `{publisher_name}`",
246 target.name()
247 )
248 })?;
249 let compatible = matches!(
250 (target, publisher),
251 (
252 TargetConfig::DockerArchive { .. },
253 PublisherConfig::GithubRelease { .. }
254 ) | (
255 TargetConfig::MavenReactor { .. },
256 PublisherConfig::GithubMaven { .. }
257 )
258 );
259 if !compatible {
260 bail!(
261 "target `{}` is incompatible with publisher `{publisher_name}`",
262 target.name()
263 );
264 }
265 }
266 match target {
267 TargetConfig::DockerArchive {
268 build,
269 local_check,
270 asset,
271 ..
272 } => {
273 validate_command(&format!("targets.{}.build", target.name()), build, false)?;
274 validate_command(
275 &format!("targets.{}.local_check", target.name()),
276 local_check,
277 false,
278 )?;
279 validate_asset_name(target.name(), asset)?;
280 }
281 TargetConfig::MavenReactor {
282 projects,
283 remote_check,
284 ..
285 } => {
286 if projects.is_empty() {
287 bail!("target `{}` must select Maven projects", target.name());
288 }
289 validate_command(
290 &format!("targets.{}.remote_check", target.name()),
291 remote_check,
292 true,
293 )?;
294 }
295 TargetConfig::OciImage {
296 image,
297 platform,
298 reuse_check,
299 build,
300 ..
301 } => {
302 crate::oci::validate_image_template(image, platform)?;
303 validate_command(
304 &format!("targets.{}.reuse_check", target.name()),
305 reuse_check,
306 false,
307 )?;
308 validate_command(&format!("targets.{}.build", target.name()), build, false)?;
309 }
310 }
311 }
312 Ok(())
313 }
314
315 fn validate_dependencies(&self) -> Result<()> {
316 let by_name = self
317 .targets
318 .iter()
319 .map(|target| (target.name(), target))
320 .collect::<BTreeMap<_, _>>();
321 for target in &self.targets {
322 let mut unique = HashSet::new();
323 for dependency in target.dependencies() {
324 if dependency == target.name() {
325 bail!("target `{}` cannot depend on itself", target.name());
326 }
327 if !unique.insert(dependency) {
328 bail!(
329 "target `{}` contains duplicate dependency `{dependency}`",
330 target.name()
331 );
332 }
333 let dependency_target = by_name.get(dependency.as_str()).with_context(|| {
334 format!(
335 "target `{}` references unknown dependency `{dependency}`",
336 target.name()
337 )
338 })?;
339 if !target.is_oci_image() || !dependency_target.is_oci_image() {
340 bail!(
341 "target `{}` cannot consume dependency `{dependency}`; v1 only supports oci_image -> oci_image dependencies",
342 target.name()
343 );
344 }
345 }
346 }
347 Ok(())
348 }
349}
350
351fn validate_asset_name(target: &str, asset: &str) -> Result<()> {
352 let asset_path = Path::new(asset);
353 if asset_path.file_name().and_then(|name| name.to_str()) != Some(asset)
354 || asset == "."
355 || asset == ".."
356 {
357 bail!("target `{target}` asset must be one file name");
358 }
359 Ok(())
360}
361
362fn validate_command(name: &str, command: &[String], allow_empty: bool) -> Result<()> {
363 if command.is_empty() {
364 if allow_empty {
365 return Ok(());
366 }
367 bail!("{name} must not be empty");
368 }
369 if command.iter().any(|argument| argument.is_empty()) {
370 bail!("{name} arguments must not be empty");
371 }
372 Ok(())
373}