use super::{PreparedDependencies, TargetAdapter};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::TargetConfig;
use crate::domain::{
ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate, TargetPlan,
};
use anyhow::{Context, Result, bail};
use roxmltree::{Document, Node};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct MavenReactorTarget {
root: PathBuf,
name: String,
publisher: String,
wrapper: PathBuf,
pom: PathBuf,
projects: Vec<String>,
also_make: bool,
remote_check: Vec<String>,
runner: Arc<dyn CommandRunner>,
}
#[derive(Clone, Debug)]
struct MavenProject {
relative_path: String,
directory: PathBuf,
group_id: String,
artifact_id: String,
version: String,
packaging: String,
dependencies: Vec<(String, String)>,
}
impl MavenReactorTarget {
pub fn new(
root: impl Into<PathBuf>,
config: &TargetConfig,
runner: Arc<dyn CommandRunner>,
) -> Result<Self> {
let TargetConfig::MavenReactor {
name,
publisher,
wrapper,
pom,
projects,
also_make,
remote_check,
..
} = config
else {
bail!("MavenReactorTarget requires a maven_reactor target config");
};
Ok(Self {
root: root.into(),
name: name.clone(),
publisher: publisher.clone(),
wrapper: wrapper.clone(),
pom: pom.clone(),
projects: projects.clone(),
also_make: *also_make,
remote_check: remote_check.clone(),
runner,
})
}
pub fn wrapper(&self) -> &Path {
&self.wrapper
}
pub fn remote_check(&self) -> &[String] {
&self.remote_check
}
fn selected_projects(&self, version: &str) -> Result<Vec<MavenProject>> {
let root_pom = self.root.join(&self.pom);
let root_source = fs::read_to_string(&root_pom)
.with_context(|| format!("failed to read {}", root_pom.display()))?;
let root_document = Document::parse(&root_source)
.with_context(|| format!("invalid Maven POM {}", root_pom.display()))?;
let root_project = root_document.root_element();
let module_names = direct_child(root_project, "modules")
.map(|modules| {
direct_children(modules, "module")
.filter_map(|module| module.text().map(str::trim).map(str::to_owned))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut modules = Vec::new();
for module in &module_names {
modules.push(read_project(&self.root, module, version)?);
}
for selected in &self.projects {
if !modules
.iter()
.any(|project| project.relative_path == *selected)
{
modules.push(read_project(&self.root, selected, version)?);
}
}
let mut by_ga = HashMap::new();
for (index, project) in modules.iter().enumerate() {
let coordinates = (project.group_id.clone(), project.artifact_id.clone());
if by_ga.insert(coordinates.clone(), index).is_some() {
bail!(
"Maven reactor contains duplicate coordinates {}:{}",
coordinates.0,
coordinates.1
);
}
}
let mut selected_indices = HashSet::new();
for selected in &self.projects {
let index = modules
.iter()
.position(|project| project.relative_path == *selected)
.with_context(|| format!("Maven project `{selected}` is not in the reactor"))?;
include_project(
index,
&modules,
&by_ga,
self.also_make,
&mut selected_indices,
);
}
Ok(modules
.into_iter()
.enumerate()
.filter_map(|(index, project)| selected_indices.contains(&index).then_some(project))
.collect())
}
fn run_build(&self, version: &str) -> Result<()> {
let mut arguments = vec![
"-f".to_owned(),
self.pom.display().to_string(),
"-pl".to_owned(),
self.projects.join(","),
];
if self.also_make {
arguments.push("-am".to_owned());
}
arguments.extend([
format!("-Drevision={version}"),
"--batch-mode".to_owned(),
"--no-transfer-progress".to_owned(),
"verify".to_owned(),
]);
let request =
CommandRequest::new(self.wrapper.display().to_string(), arguments, &self.root);
self.runner
.execute_inheriting_output(&request)?
.require_success("prepare Maven reactor artifacts")?;
Ok(())
}
}
impl TargetAdapter for MavenReactorTarget {
fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
let version = release.tag.to_string();
let projects = self.selected_projects(&version)?;
let mut artifacts = Vec::new();
for project in projects {
if project.version != version {
bail!(
"Maven project {} resolves version {}, expected release version {version}",
project.relative_path,
project.version
);
}
if project.packaging != "pom" {
artifacts.push(identity(&project, &version, &project.packaging));
}
artifacts.push(identity(&project, &version, "pom"));
}
Ok(TargetPlan {
name: self.name.clone(),
publisher: self.publisher.clone(),
release: release.clone(),
artifacts,
})
}
fn prepare(
&self,
plan: &TargetPlan,
dependencies: &PreparedDependencies,
staging: &Path,
) -> Result<ArtifactManifest> {
if !dependencies.is_empty() {
bail!("Maven reactor targets do not accept target dependencies");
}
if plan.name != self.name || plan.publisher != self.publisher {
bail!(
"target plan does not belong to Maven target `{}`",
self.name
);
}
fs::create_dir_all(staging)
.with_context(|| format!("failed to create staging directory {}", staging.display()))?;
let version = plan.release.tag.to_string();
let projects = self.selected_projects(&version)?;
self.run_build(&version)?;
let mut prepared = Vec::new();
for project in projects {
if project.version != version {
bail!(
"Maven project {} resolves version {}, expected release version {version}",
project.relative_path,
project.version
);
}
let destination = staging
.join(project.group_id.replace('.', "/"))
.join(&project.artifact_id)
.join(&version);
fs::create_dir_all(&destination)?;
if project.packaging != "pom" {
let file_name =
format!("{}-{}.{}", project.artifact_id, version, project.packaging);
let source = project.directory.join("target").join(&file_name);
let path = destination.join(&file_name);
fs::copy(&source, &path).with_context(|| {
format!(
"failed to copy prepared Maven artifact {}",
source.display()
)
})?;
prepared.push(PreparedArtifact {
identity: identity(&project, &version, &project.packaging),
sha256: sha256(&path)?,
path,
});
}
let flattened = project.directory.join("target/flattened-pom.xml");
let raw_pom = project.directory.join("pom.xml");
let source = if flattened.is_file() {
flattened
} else {
raw_pom
};
let pom_source = fs::read_to_string(&source)
.with_context(|| format!("failed to read prepared POM {}", source.display()))?
.replace("${revision}", &version);
let path = destination.join(format!("{}-{version}.pom", project.artifact_id));
fs::write(&path, pom_source)?;
prepared.push(PreparedArtifact {
identity: identity(&project, &version, "pom"),
sha256: sha256(&path)?,
path,
});
}
if prepared
.iter()
.map(|artifact| &artifact.identity)
.ne(plan.artifacts.iter())
{
bail!("prepared Maven artifact inventory differs from ReleasePlan");
}
Ok(ArtifactManifest {
target: self.name.clone(),
publisher: self.publisher.clone(),
release: plan.release.clone(),
artifacts: prepared,
})
}
}
fn read_project(root: &Path, relative_path: &str, revision: &str) -> Result<MavenProject> {
let directory = root.join(relative_path);
let path = directory.join("pom.xml");
let source = fs::read_to_string(&path)
.with_context(|| format!("failed to read Maven project {}", path.display()))?;
let document = Document::parse(&source)
.with_context(|| format!("invalid Maven POM {}", path.display()))?;
let project = document.root_element();
let parent = direct_child(project, "parent");
let group_id = direct_text(project, "groupId")
.or_else(|| parent.and_then(|parent| direct_text(parent, "groupId")))
.with_context(|| format!("Maven project `{relative_path}` has no groupId"))?;
let artifact_id = direct_text(project, "artifactId")
.with_context(|| format!("Maven project `{relative_path}` has no artifactId"))?;
let version = direct_text(project, "version")
.or_else(|| parent.and_then(|parent| direct_text(parent, "version")))
.with_context(|| format!("Maven project `{relative_path}` has no version"))?;
let group_id = resolve_model_value(&group_id, revision, relative_path, "groupId")?;
let artifact_id = resolve_model_value(&artifact_id, revision, relative_path, "artifactId")?;
let version = resolve_model_value(&version, revision, relative_path, "version")?;
let packaging = resolve_model_value(
&direct_text(project, "packaging").unwrap_or_else(|| "jar".to_owned()),
revision,
relative_path,
"packaging",
)?;
if !matches!(packaging.as_str(), "jar" | "pom") {
bail!(
"Maven project `{relative_path}` uses unsupported packaging `{packaging}`; first release supports exact jar/pom inventories only"
);
}
let mut dependencies = Vec::new();
if let Some(container) = direct_child(project, "dependencies") {
for dependency in direct_children(container, "dependency") {
let scope = direct_text(dependency, "scope");
if matches!(scope.as_deref(), Some("test") | Some("provided")) {
continue;
}
let dependency_group = direct_text(dependency, "groupId")
.with_context(|| format!("Maven dependency in `{relative_path}` has no groupId"))?;
let dependency_artifact = direct_text(dependency, "artifactId").with_context(|| {
format!("Maven dependency in `{relative_path}` has no artifactId")
})?;
dependencies.push((
resolve_model_value(
&dependency_group,
revision,
relative_path,
"dependency groupId",
)?,
resolve_model_value(
&dependency_artifact,
revision,
relative_path,
"dependency artifactId",
)?,
));
}
}
Ok(MavenProject {
relative_path: relative_path.trim_end_matches('/').to_owned(),
directory,
group_id,
artifact_id,
version,
packaging,
dependencies,
})
}
fn resolve_model_value(value: &str, revision: &str, project: &str, field: &str) -> Result<String> {
let resolved = value.replace("${revision}", revision);
if resolved.contains("${") {
bail!("Maven project `{project}` has an unresolved expression in {field}: `{value}`");
}
Ok(resolved)
}
fn include_project(
index: usize,
projects: &[MavenProject],
by_ga: &HashMap<(String, String), usize>,
include_dependencies: bool,
selected: &mut HashSet<usize>,
) {
if !selected.insert(index) || !include_dependencies {
return;
}
for dependency in &projects[index].dependencies {
if let Some(dependency_index) = by_ga.get(dependency) {
include_project(
*dependency_index,
projects,
by_ga,
include_dependencies,
selected,
);
}
}
}
fn identity(project: &MavenProject, version: &str, extension: &str) -> ArtifactIdentity {
ArtifactIdentity::MavenPackage {
group_id: project.group_id.clone(),
artifact_id: project.artifact_id.clone(),
version: version.to_owned(),
extension: extension.to_owned(),
}
}
fn direct_child<'a, 'input>(node: Node<'a, 'input>, name: &str) -> Option<Node<'a, 'input>> {
node.children()
.find(|child| child.is_element() && child.tag_name().name() == name)
}
fn direct_children<'a, 'input>(
node: Node<'a, 'input>,
name: &'a str,
) -> impl Iterator<Item = Node<'a, 'input>> + 'a {
node.children()
.filter(move |child| child.is_element() && child.tag_name().name() == name)
}
fn direct_text(node: Node<'_, '_>, name: &str) -> Option<String> {
direct_child(node, name)
.and_then(|child| child.text())
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_owned)
}
fn sha256(path: &Path) -> Result<String> {
let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(hex::encode(Sha256::digest(bytes)))
}