Skip to main content

release_tool/target/
maven_reactor.rs

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