Skip to main content

release_tool/target/
maven_reactor.rs

1use super::TargetAdapter;
2use crate::command::{CommandRequest, CommandRunner};
3use crate::config::TargetConfig;
4use crate::domain::{
5    ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate, TargetPlan,
6};
7use anyhow::{Context, Result, bail};
8use roxmltree::{Document, Node};
9use sha2::{Digest, Sha256};
10use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15pub struct MavenReactorTarget {
16    root: PathBuf,
17    name: String,
18    publisher: String,
19    wrapper: PathBuf,
20    pom: PathBuf,
21    projects: Vec<String>,
22    also_make: bool,
23    remote_check: Vec<String>,
24    runner: Arc<dyn CommandRunner>,
25}
26
27#[derive(Clone, Debug)]
28struct MavenProject {
29    relative_path: String,
30    directory: PathBuf,
31    group_id: String,
32    artifact_id: String,
33    version: String,
34    packaging: String,
35    dependencies: Vec<(String, String)>,
36}
37
38impl MavenReactorTarget {
39    pub fn new(
40        root: impl Into<PathBuf>,
41        config: &TargetConfig,
42        runner: Arc<dyn CommandRunner>,
43    ) -> Result<Self> {
44        let TargetConfig::MavenReactor {
45            name,
46            publisher,
47            wrapper,
48            pom,
49            projects,
50            also_make,
51            remote_check,
52            ..
53        } = config
54        else {
55            bail!("MavenReactorTarget requires a maven_reactor target config");
56        };
57        Ok(Self {
58            root: root.into(),
59            name: name.clone(),
60            publisher: publisher.clone(),
61            wrapper: wrapper.clone(),
62            pom: pom.clone(),
63            projects: projects.clone(),
64            also_make: *also_make,
65            remote_check: remote_check.clone(),
66            runner,
67        })
68    }
69
70    pub fn wrapper(&self) -> &Path {
71        &self.wrapper
72    }
73
74    pub fn remote_check(&self) -> &[String] {
75        &self.remote_check
76    }
77
78    fn selected_projects(&self, version: &str) -> Result<Vec<MavenProject>> {
79        let root_pom = self.root.join(&self.pom);
80        let root_source = fs::read_to_string(&root_pom)
81            .with_context(|| format!("failed to read {}", root_pom.display()))?;
82        let root_document = Document::parse(&root_source)
83            .with_context(|| format!("invalid Maven POM {}", root_pom.display()))?;
84        let root_project = root_document.root_element();
85        let module_names = direct_child(root_project, "modules")
86            .map(|modules| {
87                direct_children(modules, "module")
88                    .filter_map(|module| module.text().map(str::trim).map(str::to_owned))
89                    .collect::<Vec<_>>()
90            })
91            .unwrap_or_default();
92        let mut modules = Vec::new();
93        for module in &module_names {
94            modules.push(read_project(&self.root, module, version)?);
95        }
96        for selected in &self.projects {
97            if !modules
98                .iter()
99                .any(|project| project.relative_path == *selected)
100            {
101                modules.push(read_project(&self.root, selected, version)?);
102            }
103        }
104
105        let mut by_ga = HashMap::new();
106        for (index, project) in modules.iter().enumerate() {
107            let coordinates = (project.group_id.clone(), project.artifact_id.clone());
108            if by_ga.insert(coordinates.clone(), index).is_some() {
109                bail!(
110                    "Maven reactor contains duplicate coordinates {}:{}",
111                    coordinates.0,
112                    coordinates.1
113                );
114            }
115        }
116        let mut selected_indices = HashSet::new();
117        for selected in &self.projects {
118            let index = modules
119                .iter()
120                .position(|project| project.relative_path == *selected)
121                .with_context(|| format!("Maven project `{selected}` is not in the reactor"))?;
122            include_project(
123                index,
124                &modules,
125                &by_ga,
126                self.also_make,
127                &mut selected_indices,
128            );
129        }
130        Ok(modules
131            .into_iter()
132            .enumerate()
133            .filter_map(|(index, project)| selected_indices.contains(&index).then_some(project))
134            .collect())
135    }
136
137    fn run_build(&self, version: &str) -> Result<()> {
138        let mut arguments = vec![
139            "-f".to_owned(),
140            self.pom.display().to_string(),
141            "-pl".to_owned(),
142            self.projects.join(","),
143        ];
144        if self.also_make {
145            arguments.push("-am".to_owned());
146        }
147        arguments.extend([
148            format!("-Drevision={version}"),
149            "--batch-mode".to_owned(),
150            "--no-transfer-progress".to_owned(),
151            "verify".to_owned(),
152        ]);
153        let request =
154            CommandRequest::new(self.wrapper.display().to_string(), arguments, &self.root);
155        self.runner
156            .execute_inheriting_output(&request)?
157            .require_success("prepare Maven reactor artifacts")?;
158        Ok(())
159    }
160}
161
162impl TargetAdapter for MavenReactorTarget {
163    fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
164        let version = release.tag.to_string();
165        let projects = self.selected_projects(&version)?;
166        let mut artifacts = Vec::new();
167        for project in projects {
168            if project.version != version {
169                bail!(
170                    "Maven project {} resolves version {}, expected release version {version}",
171                    project.relative_path,
172                    project.version
173                );
174            }
175            if project.packaging != "pom" {
176                artifacts.push(identity(&project, &version, &project.packaging));
177            }
178            artifacts.push(identity(&project, &version, "pom"));
179        }
180        Ok(TargetPlan {
181            name: self.name.clone(),
182            publisher: self.publisher.clone(),
183            release: release.clone(),
184            artifacts,
185        })
186    }
187
188    fn prepare(&self, plan: &TargetPlan, staging: &Path) -> Result<ArtifactManifest> {
189        if plan.name != self.name || plan.publisher != self.publisher {
190            bail!(
191                "target plan does not belong to Maven target `{}`",
192                self.name
193            );
194        }
195        fs::create_dir_all(staging)
196            .with_context(|| format!("failed to create staging directory {}", staging.display()))?;
197        let version = plan.release.tag.to_string();
198        let projects = self.selected_projects(&version)?;
199        self.run_build(&version)?;
200        let mut prepared = Vec::new();
201        for project in projects {
202            if project.version != version {
203                bail!(
204                    "Maven project {} resolves version {}, expected release version {version}",
205                    project.relative_path,
206                    project.version
207                );
208            }
209            let destination = staging
210                .join(project.group_id.replace('.', "/"))
211                .join(&project.artifact_id)
212                .join(&version);
213            fs::create_dir_all(&destination)?;
214            if project.packaging != "pom" {
215                let file_name =
216                    format!("{}-{}.{}", project.artifact_id, version, project.packaging);
217                let source = project.directory.join("target").join(&file_name);
218                let path = destination.join(&file_name);
219                fs::copy(&source, &path).with_context(|| {
220                    format!(
221                        "failed to copy prepared Maven artifact {}",
222                        source.display()
223                    )
224                })?;
225                prepared.push(PreparedArtifact {
226                    identity: identity(&project, &version, &project.packaging),
227                    sha256: sha256(&path)?,
228                    path,
229                });
230            }
231            let flattened = project.directory.join("target/flattened-pom.xml");
232            let raw_pom = project.directory.join("pom.xml");
233            let source = if flattened.is_file() {
234                flattened
235            } else {
236                raw_pom
237            };
238            let pom_source = fs::read_to_string(&source)
239                .with_context(|| format!("failed to read prepared POM {}", source.display()))?
240                .replace("${revision}", &version);
241            let path = destination.join(format!("{}-{version}.pom", project.artifact_id));
242            fs::write(&path, pom_source)?;
243            prepared.push(PreparedArtifact {
244                identity: identity(&project, &version, "pom"),
245                sha256: sha256(&path)?,
246                path,
247            });
248        }
249        if prepared
250            .iter()
251            .map(|artifact| &artifact.identity)
252            .ne(plan.artifacts.iter())
253        {
254            bail!("prepared Maven artifact inventory differs from ReleasePlan");
255        }
256        Ok(ArtifactManifest {
257            target: self.name.clone(),
258            publisher: self.publisher.clone(),
259            release: plan.release.clone(),
260            artifacts: prepared,
261        })
262    }
263}
264
265fn read_project(root: &Path, relative_path: &str, revision: &str) -> Result<MavenProject> {
266    let directory = root.join(relative_path);
267    let path = directory.join("pom.xml");
268    let source = fs::read_to_string(&path)
269        .with_context(|| format!("failed to read Maven project {}", path.display()))?;
270    let document = Document::parse(&source)
271        .with_context(|| format!("invalid Maven POM {}", path.display()))?;
272    let project = document.root_element();
273    let parent = direct_child(project, "parent");
274    let group_id = direct_text(project, "groupId")
275        .or_else(|| parent.and_then(|parent| direct_text(parent, "groupId")))
276        .with_context(|| format!("Maven project `{relative_path}` has no groupId"))?;
277    let artifact_id = direct_text(project, "artifactId")
278        .with_context(|| format!("Maven project `{relative_path}` has no artifactId"))?;
279    let version = direct_text(project, "version")
280        .or_else(|| parent.and_then(|parent| direct_text(parent, "version")))
281        .with_context(|| format!("Maven project `{relative_path}` has no version"))?;
282    let group_id = resolve_model_value(&group_id, revision, relative_path, "groupId")?;
283    let artifact_id = resolve_model_value(&artifact_id, revision, relative_path, "artifactId")?;
284    let version = resolve_model_value(&version, revision, relative_path, "version")?;
285    let packaging = resolve_model_value(
286        &direct_text(project, "packaging").unwrap_or_else(|| "jar".to_owned()),
287        revision,
288        relative_path,
289        "packaging",
290    )?;
291    if !matches!(packaging.as_str(), "jar" | "pom") {
292        bail!(
293            "Maven project `{relative_path}` uses unsupported packaging `{packaging}`; first release supports exact jar/pom inventories only"
294        );
295    }
296    let mut dependencies = Vec::new();
297    if let Some(container) = direct_child(project, "dependencies") {
298        for dependency in direct_children(container, "dependency") {
299            let scope = direct_text(dependency, "scope");
300            if matches!(scope.as_deref(), Some("test") | Some("provided")) {
301                continue;
302            }
303            let dependency_group = direct_text(dependency, "groupId")
304                .with_context(|| format!("Maven dependency in `{relative_path}` has no groupId"))?;
305            let dependency_artifact = direct_text(dependency, "artifactId").with_context(|| {
306                format!("Maven dependency in `{relative_path}` has no artifactId")
307            })?;
308            dependencies.push((
309                resolve_model_value(
310                    &dependency_group,
311                    revision,
312                    relative_path,
313                    "dependency groupId",
314                )?,
315                resolve_model_value(
316                    &dependency_artifact,
317                    revision,
318                    relative_path,
319                    "dependency artifactId",
320                )?,
321            ));
322        }
323    }
324    Ok(MavenProject {
325        relative_path: relative_path.trim_end_matches('/').to_owned(),
326        directory,
327        group_id,
328        artifact_id,
329        version,
330        packaging,
331        dependencies,
332    })
333}
334
335fn resolve_model_value(value: &str, revision: &str, project: &str, field: &str) -> Result<String> {
336    let resolved = value.replace("${revision}", revision);
337    if resolved.contains("${") {
338        bail!("Maven project `{project}` has an unresolved expression in {field}: `{value}`");
339    }
340    Ok(resolved)
341}
342
343fn include_project(
344    index: usize,
345    projects: &[MavenProject],
346    by_ga: &HashMap<(String, String), usize>,
347    include_dependencies: bool,
348    selected: &mut HashSet<usize>,
349) {
350    if !selected.insert(index) || !include_dependencies {
351        return;
352    }
353    for dependency in &projects[index].dependencies {
354        if let Some(dependency_index) = by_ga.get(dependency) {
355            include_project(
356                *dependency_index,
357                projects,
358                by_ga,
359                include_dependencies,
360                selected,
361            );
362        }
363    }
364}
365
366fn identity(project: &MavenProject, version: &str, extension: &str) -> ArtifactIdentity {
367    ArtifactIdentity::MavenPackage {
368        group_id: project.group_id.clone(),
369        artifact_id: project.artifact_id.clone(),
370        version: version.to_owned(),
371        extension: extension.to_owned(),
372    }
373}
374
375fn direct_child<'a, 'input>(node: Node<'a, 'input>, name: &str) -> Option<Node<'a, 'input>> {
376    node.children()
377        .find(|child| child.is_element() && child.tag_name().name() == name)
378}
379
380fn direct_children<'a, 'input>(
381    node: Node<'a, 'input>,
382    name: &'a str,
383) -> impl Iterator<Item = Node<'a, 'input>> + 'a {
384    node.children()
385        .filter(move |child| child.is_element() && child.tag_name().name() == name)
386}
387
388fn direct_text(node: Node<'_, '_>, name: &str) -> Option<String> {
389    direct_child(node, name)
390        .and_then(|child| child.text())
391        .map(str::trim)
392        .filter(|text| !text.is_empty())
393        .map(str::to_owned)
394}
395
396fn sha256(path: &Path) -> Result<String> {
397    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
398    Ok(hex::encode(Sha256::digest(bytes)))
399}