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}