Skip to main content

release_tool/
config.rs

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}