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}