release-tool 0.2.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use super::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, staging: &Path) -> Result<ArtifactManifest> {
        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)))
}