Skip to main content

release_tool/target/
maven_reactor.rs

1use super::{PreparedDependencies, 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(
189        &self,
190        plan: &TargetPlan,
191        dependencies: &PreparedDependencies,
192        staging: &Path,
193    ) -> Result<ArtifactManifest> {
194        if !dependencies.is_empty() {
195            bail!("Maven reactor targets do not accept target dependencies");
196        }
197        if plan.name != self.name || plan.publisher != self.publisher {
198            bail!(
199                "target plan does not belong to Maven target `{}`",
200                self.name
201            );
202        }
203        fs::create_dir_all(staging)
204            .with_context(|| format!("failed to create staging directory {}", staging.display()))?;
205        let version = plan.release.tag.to_string();
206        let projects = self.selected_projects(&version)?;
207        self.run_build(&version)?;
208        let mut prepared = Vec::new();
209        for project in projects {
210            if project.version != version {
211                bail!(
212                    "Maven project {} resolves version {}, expected release version {version}",
213                    project.relative_path,
214                    project.version
215                );
216            }
217            let destination = staging
218                .join(project.group_id.replace('.', "/"))
219                .join(&project.artifact_id)
220                .join(&version);
221            fs::create_dir_all(&destination)?;
222            if project.packaging != "pom" {
223                let file_name =
224                    format!("{}-{}.{}", project.artifact_id, version, project.packaging);
225                let source = project.directory.join("target").join(&file_name);
226                let path = destination.join(&file_name);
227                fs::copy(&source, &path).with_context(|| {
228                    format!(
229                        "failed to copy prepared Maven artifact {}",
230                        source.display()
231                    )
232                })?;
233                prepared.push(PreparedArtifact {
234                    identity: identity(&project, &version, &project.packaging),
235                    sha256: sha256(&path)?,
236                    path,
237                });
238            }
239            let flattened = project.directory.join("target/flattened-pom.xml");
240            let raw_pom = project.directory.join("pom.xml");
241            let source = if flattened.is_file() {
242                flattened
243            } else {
244                raw_pom
245            };
246            let pom_source = fs::read_to_string(&source)
247                .with_context(|| format!("failed to read prepared POM {}", source.display()))?
248                .replace("${revision}", &version);
249            let path = destination.join(format!("{}-{version}.pom", project.artifact_id));
250            fs::write(&path, pom_source)?;
251            prepared.push(PreparedArtifact {
252                identity: identity(&project, &version, "pom"),
253                sha256: sha256(&path)?,
254                path,
255            });
256        }
257        if prepared
258            .iter()
259            .map(|artifact| &artifact.identity)
260            .ne(plan.artifacts.iter())
261        {
262            bail!("prepared Maven artifact inventory differs from ReleasePlan");
263        }
264        Ok(ArtifactManifest {
265            target: self.name.clone(),
266            publisher: self.publisher.clone(),
267            release: plan.release.clone(),
268            artifacts: prepared,
269        })
270    }
271}
272
273fn read_project(root: &Path, relative_path: &str, revision: &str) -> Result<MavenProject> {
274    let directory = root.join(relative_path);
275    let path = directory.join("pom.xml");
276    let source = fs::read_to_string(&path)
277        .with_context(|| format!("failed to read Maven project {}", path.display()))?;
278    let document = Document::parse(&source)
279        .with_context(|| format!("invalid Maven POM {}", path.display()))?;
280    let project = document.root_element();
281    let parent = direct_child(project, "parent");
282    let group_id = direct_text(project, "groupId")
283        .or_else(|| parent.and_then(|parent| direct_text(parent, "groupId")))
284        .with_context(|| format!("Maven project `{relative_path}` has no groupId"))?;
285    let artifact_id = direct_text(project, "artifactId")
286        .with_context(|| format!("Maven project `{relative_path}` has no artifactId"))?;
287    let version = direct_text(project, "version")
288        .or_else(|| parent.and_then(|parent| direct_text(parent, "version")))
289        .with_context(|| format!("Maven project `{relative_path}` has no version"))?;
290    let group_id = resolve_model_value(&group_id, revision, relative_path, "groupId")?;
291    let artifact_id = resolve_model_value(&artifact_id, revision, relative_path, "artifactId")?;
292    let version = resolve_model_value(&version, revision, relative_path, "version")?;
293    let packaging = resolve_model_value(
294        &direct_text(project, "packaging").unwrap_or_else(|| "jar".to_owned()),
295        revision,
296        relative_path,
297        "packaging",
298    )?;
299    if !matches!(packaging.as_str(), "jar" | "pom") {
300        bail!(
301            "Maven project `{relative_path}` uses unsupported packaging `{packaging}`; first release supports exact jar/pom inventories only"
302        );
303    }
304    let mut dependencies = Vec::new();
305    if let Some(container) = direct_child(project, "dependencies") {
306        for dependency in direct_children(container, "dependency") {
307            let scope = direct_text(dependency, "scope");
308            if matches!(scope.as_deref(), Some("test") | Some("provided")) {
309                continue;
310            }
311            let dependency_group = direct_text(dependency, "groupId")
312                .with_context(|| format!("Maven dependency in `{relative_path}` has no groupId"))?;
313            let dependency_artifact = direct_text(dependency, "artifactId").with_context(|| {
314                format!("Maven dependency in `{relative_path}` has no artifactId")
315            })?;
316            dependencies.push((
317                resolve_model_value(
318                    &dependency_group,
319                    revision,
320                    relative_path,
321                    "dependency groupId",
322                )?,
323                resolve_model_value(
324                    &dependency_artifact,
325                    revision,
326                    relative_path,
327                    "dependency artifactId",
328                )?,
329            ));
330        }
331    }
332    Ok(MavenProject {
333        relative_path: relative_path.trim_end_matches('/').to_owned(),
334        directory,
335        group_id,
336        artifact_id,
337        version,
338        packaging,
339        dependencies,
340    })
341}
342
343fn resolve_model_value(value: &str, revision: &str, project: &str, field: &str) -> Result<String> {
344    let resolved = value.replace("${revision}", revision);
345    if resolved.contains("${") {
346        bail!("Maven project `{project}` has an unresolved expression in {field}: `{value}`");
347    }
348    Ok(resolved)
349}
350
351fn include_project(
352    index: usize,
353    projects: &[MavenProject],
354    by_ga: &HashMap<(String, String), usize>,
355    include_dependencies: bool,
356    selected: &mut HashSet<usize>,
357) {
358    if !selected.insert(index) || !include_dependencies {
359        return;
360    }
361    for dependency in &projects[index].dependencies {
362        if let Some(dependency_index) = by_ga.get(dependency) {
363            include_project(
364                *dependency_index,
365                projects,
366                by_ga,
367                include_dependencies,
368                selected,
369            );
370        }
371    }
372}
373
374fn identity(project: &MavenProject, version: &str, extension: &str) -> ArtifactIdentity {
375    ArtifactIdentity::MavenPackage {
376        group_id: project.group_id.clone(),
377        artifact_id: project.artifact_id.clone(),
378        version: version.to_owned(),
379        extension: extension.to_owned(),
380    }
381}
382
383fn direct_child<'a, 'input>(node: Node<'a, 'input>, name: &str) -> Option<Node<'a, 'input>> {
384    node.children()
385        .find(|child| child.is_element() && child.tag_name().name() == name)
386}
387
388fn direct_children<'a, 'input>(
389    node: Node<'a, 'input>,
390    name: &'a str,
391) -> impl Iterator<Item = Node<'a, 'input>> + 'a {
392    node.children()
393        .filter(move |child| child.is_element() && child.tag_name().name() == name)
394}
395
396fn direct_text(node: Node<'_, '_>, name: &str) -> Option<String> {
397    direct_child(node, name)
398        .and_then(|child| child.text())
399        .map(str::trim)
400        .filter(|text| !text.is_empty())
401        .map(str::to_owned)
402}
403
404fn sha256(path: &Path) -> Result<String> {
405    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
406    Ok(hex::encode(Sha256::digest(bytes)))
407}