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}