use crate::compile::{flat_package_src_dirs, flat_package_test_dirs};
use crate::descriptor::{self, Descriptor, DependencyValue, Relocation, Resources};
use crate::incremental::walk_files;
use crate::workspace;
use anyhow::{Context, Result};
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::io::Cursor;
use std::path::{Path, PathBuf};
pub const GENERATED_GROUP_ID: &str = "curie.generated";
pub const GENERATED_VERSION: &str = "0";
pub const OUTPUT_TIMESTAMP: &str = "2024-01-01T00:00:00Z";
pub const SCHEMA_VERSION: u32 = 3;
const GENERATED_MARKER: &str = "Generated by curie maven sync from Curie.toml — DO NOT EDIT.";
const FINGERPRINT_PREFIX: &str = "curie-maven-fingerprint: ";
const DEFAULT_PLUGIN_GROUP_ID: &str = "org.apache.maven.plugins";
pub mod plugin_versions {
pub const COMPILER: &str = "3.15.0";
pub const SUREFIRE: &str = "3.5.6";
pub const JAR: &str = "3.5.0";
pub const SHADE: &str = "3.6.2";
pub const DEPENDENCY: &str = "3.11.0";
pub const BUILD_HELPER: &str = "3.6.0";
pub const GMAVENPLUS: &str = "4.2.0";
pub const JACOCO: &str = "0.8.13";
pub const RESOURCES: &str = "3.3.1";
pub const PROTOBUF_MAVEN: &str = "5.1.2";
}
const BUILD_HELPER_GROUP_ID: &str = "org.codehaus.mojo";
const BUILD_HELPER_ARTIFACT_ID: &str = "build-helper-maven-plugin";
const KOTLIN_GROUP_ID: &str = "org.jetbrains.kotlin";
const KOTLIN_PLUGIN_ARTIFACT_ID: &str = "kotlin-maven-plugin";
const KOTLIN_STDLIB_ARTIFACT_ID: &str = "kotlin-stdlib";
const GMAVENPLUS_GROUP_ID: &str = "org.codehaus.gmavenplus";
const GMAVENPLUS_ARTIFACT_ID: &str = "gmavenplus-plugin";
const PROTOBUF_MAVEN_GROUP_ID: &str = "io.github.ascopes";
const PROTOBUF_MAVEN_ARTIFACT_ID: &str = "protobuf-maven-plugin";
const OPENAPI_GENERATOR_GROUP_ID: &str = "org.openapitools";
const OPENAPI_GENERATOR_ARTIFACT_ID: &str = "openapi-generator-maven-plugin";
const GROOVY_GROUP_ID: &str = "org.apache.groovy";
const GROOVY_ARTIFACT_ID: &str = "groovy";
const SPOCK_GROUP_ID: &str = "org.spockframework";
const SPOCK_CORE_ARTIFACT_ID: &str = "spock-core";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MavenLayout {
pub src_roots: Vec<PathBuf>,
pub test_roots: Vec<PathBuf>,
pub resources: Option<PathBuf>,
pub test_resources: Option<PathBuf>,
pub colocated_test_excludes: Vec<String>,
}
pub fn discover_layout(project_root: &Path) -> MavenLayout {
let mut src_roots = production_source_roots(project_root);
let mut test_roots = test_source_roots(project_root);
let colocated_test_excludes = colocated_test_exclude_patterns(project_root, &src_roots);
if !colocated_test_excludes.is_empty() {
for root in &src_roots {
if !test_roots.contains(root) {
test_roots.push(root.clone());
}
}
}
src_roots.sort();
test_roots.sort();
MavenLayout {
src_roots,
test_roots,
resources: first_existing_dir(project_root, &["src/main/resources", "resources"]),
test_resources: first_existing_dir(project_root, &["src/test/resources", "test-resources"]),
colocated_test_excludes,
}
}
fn production_source_roots(project_root: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
for candidate in ["src/main/java", "src/main/kotlin", "src/main/groovy"] {
if project_root.join(candidate).exists() {
roots.push(PathBuf::from(candidate));
}
}
roots.extend(relative_to(project_root, flat_package_src_dirs(project_root)));
let bare_src = project_root.join("src");
if bare_src.exists() && !roots.iter().any(|r| r == Path::new("src")) && has_direct_sources(&bare_src) {
roots.push(PathBuf::from("src"));
}
roots
}
fn test_source_roots(project_root: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
for candidate in ["src/test/java", "src/test/kotlin", "src/test/groovy"] {
if project_root.join(candidate).exists() {
roots.push(PathBuf::from(candidate));
}
}
roots.extend(relative_to(project_root, flat_package_test_dirs(project_root)));
roots
}
fn relative_to(project_root: &Path, paths: Vec<PathBuf>) -> Vec<PathBuf> {
paths
.into_iter()
.filter_map(|p| p.strip_prefix(project_root).ok().map(Path::to_path_buf))
.collect()
}
fn has_direct_sources(dir: &Path) -> bool {
std::fs::read_dir(dir)
.ok()
.map(|entries| {
entries.filter_map(|e| e.ok()).any(|e| {
e.file_type().map(|t| t.is_file()).unwrap_or(false)
&& matches!(
e.path().extension().and_then(|s| s.to_str()),
Some("java") | Some("kt") | Some("groovy")
)
})
})
.unwrap_or(false)
}
fn first_existing_dir(project_root: &Path, candidates: &[&str]) -> Option<PathBuf> {
candidates
.iter()
.map(PathBuf::from)
.find(|rel| project_root.join(rel).exists())
}
fn colocated_test_exclude_patterns(project_root: &Path, src_roots: &[PathBuf]) -> Vec<String> {
let mut extensions: BTreeSet<&str> = BTreeSet::new();
for root in src_roots {
for entry in walk_files(&project_root.join(root)) {
let name = entry.file_name().to_string_lossy().into_owned();
for ext in ["java", "kt", "groovy"] {
if name.ends_with(&format!("Test.{ext}"))
|| name.ends_with(&format!("Tests.{ext}"))
|| name.ends_with(&format!("Spec.{ext}"))
{
extensions.insert(ext);
}
}
}
}
let mut patterns = Vec::new();
for ext in extensions {
patterns.push(format!("**/*Test.{ext}"));
patterns.push(format!("**/*Tests.{ext}"));
patterns.push(format!("**/*Spec.{ext}"));
}
patterns
}
fn layout_has_extension(project_root: &Path, layout: &MavenLayout, ext: &str) -> bool {
layout.src_roots.iter().chain(&layout.test_roots).any(|root| dir_has_extension(project_root, root, ext))
}
fn dir_has_extension(project_root: &Path, root: &Path, ext: &str) -> bool {
walk_files(&project_root.join(root)).any(|entry| entry.path().extension().and_then(|e| e.to_str()) == Some(ext))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MavenDependency {
pub group_id: String,
pub artifact_id: String,
pub version: Option<String>,
pub scope: Option<String>,
pub exclusions: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MavenCoordinate {
pub group_id: String,
pub artifact_id: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PinnedDependency {
pub group_id: String,
pub artifact_id: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MavenManagedDependency {
pub group_id: String,
pub artifact_id: String,
pub version: String,
pub is_import: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MavenRepository {
pub id: String,
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XmlNode {
Text(String, String),
Element(String, Vec<XmlNode>),
ElementWithAttr(String, (String, String), Vec<XmlNode>),
}
impl XmlNode {
pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
XmlNode::Text(name.into(), value.into())
}
pub fn element(name: impl Into<String>, children: Vec<XmlNode>) -> Self {
XmlNode::Element(name.into(), children)
}
pub fn element_with_attr(name: impl Into<String>, attr: (impl Into<String>, impl Into<String>), children: Vec<XmlNode>) -> Self {
XmlNode::ElementWithAttr(name.into(), (attr.0.into(), attr.1.into()), children)
}
}
#[derive(Debug, Clone, Default)]
pub struct MavenPlugin {
pub group_id: Option<String>,
pub artifact_id: String,
pub version: String,
pub configuration: Vec<XmlNode>,
pub executions: Vec<MavenExecution>,
}
#[derive(Debug, Clone, Default)]
pub struct MavenExecution {
pub id: Option<String>,
pub phase: Option<String>,
pub goals: Vec<String>,
pub configuration: Vec<XmlNode>,
}
#[derive(Debug, Clone, Default)]
pub struct MavenProject {
pub group_id: String,
pub artifact_id: String,
pub version: String,
pub packaging: String,
pub properties: Vec<(String, String)>,
pub layout: MavenLayout,
pub dependencies: Vec<MavenDependency>,
pub managed_dependencies: Vec<MavenManagedDependency>,
pub repositories: Vec<MavenRepository>,
pub plugins: Vec<MavenPlugin>,
pub modules: Vec<String>,
pub resource_filtering: ResourceFiltering,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceFiltering {
pub active: bool,
pub main: Vec<MavenResourceEntry>,
pub test: Vec<MavenResourceEntry>,
pub filters: Vec<String>,
pub delimiter: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MavenResourceEntry {
pub directory: String,
pub filtering: bool,
pub includes: Vec<String>,
pub excludes: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
pub fn build_project(
desc: &Descriptor,
project_root: &Path,
pinned: &[PinnedDependency],
resolved_ap_versions: &BTreeMap<String, String>,
main_class: Option<&str>,
workspace_member_gavs: &BTreeMap<String, MavenCoordinate>,
) -> Result<MavenProject> {
if desc.is_workspace() {
anyhow::bail!("build_project: workspace descriptors are handled by the aggregator generator");
}
if desc.is_bom() {
anyhow::bail!("build_project: BOM descriptors are handled by the BOM POM generator");
}
let layout = discover_layout(project_root);
let has_kotlin = layout_has_extension(project_root, &layout, "kt");
let has_groovy = layout_has_extension(project_root, &layout, "groovy");
let is_modular = layout.src_roots.iter().any(|root| root.join("module-info.java").exists())
|| layout.src_roots.iter().any(|root| {
std::fs::read_dir(root).ok().map(|entries| {
entries
.filter_map(|e| e.ok())
.any(|e| e.file_name() == "module-info.java")
}).unwrap_or(false)
});
let prod_ap = ap_coordinates(&desc.ap_pairs(), resolved_ap_versions)?;
let test_ap = merge_ap_coordinates(&prod_ap, &desc.test_ap_pairs(), resolved_ap_versions)?;
let mut dependencies = build_dependencies(desc)?;
dependencies.extend(workspace_dependency_entries(desc, workspace_member_gavs)?);
dependencies.extend(provided_dependencies(desc, &prod_ap, &test_ap)?);
dependencies.extend(junit_standalone_dependency(desc));
if has_kotlin {
dependencies.push(kotlin_stdlib_dependency(desc));
}
if has_groovy {
dependencies.push(groovy_dependency(desc));
}
if desc.spock.enabled() {
dependencies.push(spock_core_dependency(desc));
}
let populate_libs = desc.application().is_some()
&& !desc.fat_jar.enabled
&& (!desc.dependencies.is_empty() || has_groovy);
let mut plugins = Vec::new();
if has_kotlin {
plugins.push(build_kotlin_plugin(desc, &layout));
}
plugins.extend(build_compiler_plugin(desc, &layout, &prod_ap, &test_ap, has_kotlin)?);
if has_groovy {
plugins.push(build_gmavenplus_plugin(project_root, &layout));
}
plugins.push(build_surefire_plugin(desc, is_modular));
if desc.test.coverage_enabled() {
plugins.push(build_jacoco_plugin());
}
plugins.push(build_jar_plugin(main_class, populate_libs));
if let Some(dep_plugin) = build_dependency_plugin(dependency_plugin_executions(desc, populate_libs)) {
plugins.push(dep_plugin);
}
if let Some(shade_plugin) = build_shade_plugin(desc, main_class) {
plugins.push(shade_plugin);
}
plugins.extend(build_source_generator_plugins(desc)?);
plugins.extend(build_helper_plugins(&layout));
let resource_filtering = build_resource_filtering(desc, &layout)?;
if let Some(plugin) = build_resources_plugin(&resource_filtering) {
plugins.push(plugin);
}
let mut properties = default_properties(desc);
properties.extend(merge_resource_properties(desc)?);
Ok(MavenProject {
group_id: desc.group_id().unwrap_or(GENERATED_GROUP_ID).to_string(),
artifact_id: desc.buildable_name().to_string(),
version: desc.buildable_version().to_string(),
packaging: "jar".to_string(),
properties,
dependencies,
managed_dependencies: build_managed_dependencies(desc, pinned)?,
repositories: build_repositories(desc),
layout,
plugins,
modules: Vec::new(),
resource_filtering,
})
}
fn build_repositories(desc: &Descriptor) -> Vec<MavenRepository> {
desc.repositories
.iter()
.map(|repo| MavenRepository {
id: repo.id.clone(),
name: repo.display_name().to_string(),
url: repo.url.clone(),
})
.collect()
}
pub fn build_bom_project(desc: &Descriptor, pinned: &[PinnedDependency]) -> Result<MavenProject> {
if !desc.is_bom() {
anyhow::bail!("build_bom_project: descriptor is not a [bom]");
}
let group_id = desc
.group_id()
.ok_or_else(|| anyhow::anyhow!("groupId must be set on [bom] to sync a pom.xml"))?
.to_string();
let mut managed_dependencies = build_managed_dependencies(desc, pinned)?;
for (coord, dep) in &desc.dependencies {
let (group_id, artifact_id) = split_coord(coord)?;
managed_dependencies.push(MavenManagedDependency {
group_id,
artifact_id,
version: dep.version().to_string(),
is_import: false,
});
}
Ok(MavenProject {
group_id,
artifact_id: desc.buildable_name().to_string(),
version: desc.buildable_version().to_string(),
packaging: "pom".to_string(),
managed_dependencies,
..Default::default()
})
}
pub fn build_workspace_project(desc: &Descriptor, project_root: &Path) -> Result<MavenProject> {
let workspace = desc
.workspace()
.ok_or_else(|| anyhow::anyhow!("build_workspace_project: descriptor is not a [workspace]"))?;
let artifact_id = project_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "workspace".to_string());
Ok(MavenProject {
group_id: GENERATED_GROUP_ID.to_string(),
artifact_id,
version: GENERATED_VERSION.to_string(),
packaging: "pom".to_string(),
modules: workspace.members.clone(),
..Default::default()
})
}
fn default_properties(desc: &Descriptor) -> Vec<(String, String)> {
let mut props = vec![
("project.build.sourceEncoding".to_string(), "UTF-8".to_string()),
("project.build.outputTimestamp".to_string(), OUTPUT_TIMESTAMP.to_string()),
];
if let Some(release) = desc.java.effective() {
props.insert(0, ("maven.compiler.release".to_string(), release.to_string()));
}
props
}
fn build_dependencies(desc: &Descriptor) -> Result<Vec<MavenDependency>> {
let mut deps = Vec::new();
for (coord, dep) in &desc.dependencies {
deps.push(maven_dependency(coord, dep, "compile")?);
}
for (coord, dep) in &desc.test_dependencies {
deps.push(maven_dependency(coord, dep, "test")?);
}
Ok(deps)
}
fn workspace_dependency_entries(
desc: &Descriptor,
workspace_member_gavs: &BTreeMap<String, MavenCoordinate>,
) -> Result<Vec<MavenDependency>> {
let mut deps = Vec::new();
for (label, dep) in &desc.workspace_dependencies {
let gav = workspace_member_gavs.get(&dep.path).ok_or_else(|| {
anyhow::anyhow!("[workspace-dependencies.{label}]: no resolved GAV for member path \"{}\"", dep.path)
})?;
deps.push(MavenDependency {
group_id: gav.group_id.clone(),
artifact_id: gav.artifact_id.clone(),
version: Some(gav.version.clone()),
scope: Some("compile".to_string()),
exclusions: Vec::new(),
});
}
Ok(deps)
}
fn maven_dependency(coord: &str, dep: &DependencyValue, scope: &str) -> Result<MavenDependency> {
let (group_id, artifact_id) = split_coord(coord)?;
let version = dep.version();
let exclusions = dep
.exclusions()
.iter()
.map(|e| split_coord(e))
.collect::<Result<Vec<_>>>()?;
Ok(MavenDependency {
group_id,
artifact_id,
version: if version.is_empty() { None } else { Some(version.to_string()) },
scope: Some(scope.to_string()),
exclusions,
})
}
fn build_managed_dependencies(desc: &Descriptor, pinned: &[PinnedDependency]) -> Result<Vec<MavenManagedDependency>> {
let mut managed = Vec::new();
let mut sorted_pins = pinned.to_vec();
sorted_pins.sort();
for pin in sorted_pins {
managed.push(MavenManagedDependency {
group_id: pin.group_id,
artifact_id: pin.artifact_id,
version: pin.version,
is_import: false,
});
}
for (coord, version) in desc.bom_imports.iter().chain(desc.test_bom_imports.iter()) {
let (group_id, artifact_id) = split_coord(coord)?;
managed.push(MavenManagedDependency { group_id, artifact_id, version: version.clone(), is_import: true });
}
for (coord, version) in desc.inherited_bom_imports.iter().chain(desc.inherited_test_bom_imports.iter()) {
let (group_id, artifact_id) = split_coord(coord)?;
managed.push(MavenManagedDependency { group_id, artifact_id, version: version.clone(), is_import: true });
}
Ok(managed)
}
fn split_coord(coord: &str) -> Result<(String, String)> {
let mut parts = coord.splitn(2, ':');
let group = parts.next().ok_or_else(|| anyhow::anyhow!("invalid coordinate: {coord}"))?;
let artifact = parts.next().ok_or_else(|| anyhow::anyhow!("invalid coordinate (missing ':'): {coord}"))?;
Ok((group.to_string(), artifact.to_string()))
}
fn resolve_ap_version(coord: &str, declared: &str, resolved: &BTreeMap<String, String>) -> Result<String> {
if !declared.is_empty() {
return Ok(declared.to_string());
}
resolved.get(coord).cloned().ok_or_else(|| {
anyhow::anyhow!("annotation processor '{coord}' has no version (BOM-managed) and no resolved version was supplied")
})
}
fn ap_coordinates(pairs: &[(&str, &str)], resolved: &BTreeMap<String, String>) -> Result<Vec<MavenCoordinate>> {
pairs
.iter()
.map(|(coord, version)| {
let (group_id, artifact_id) = split_coord(coord)?;
let version = resolve_ap_version(coord, version, resolved)?;
Ok(MavenCoordinate { group_id, artifact_id, version })
})
.collect()
}
fn merge_ap_coordinates(
prod: &[MavenCoordinate],
test_pairs: &[(&str, &str)],
resolved: &BTreeMap<String, String>,
) -> Result<Vec<MavenCoordinate>> {
let mut merged = prod.to_vec();
for (coord, version) in test_pairs {
let (group_id, artifact_id) = split_coord(coord)?;
if merged.iter().any(|c| c.group_id == group_id && c.artifact_id == artifact_id) {
continue;
}
let version = resolve_ap_version(coord, version, resolved)?;
merged.push(MavenCoordinate { group_id, artifact_id, version });
}
Ok(merged)
}
fn provided_dependencies(desc: &Descriptor, prod_ap: &[MavenCoordinate], test_ap: &[MavenCoordinate]) -> Result<Vec<MavenDependency>> {
let mut deps = Vec::new();
for coord in desc.test_ap_on_compile_classpath_coords() {
let (group_id, artifact_id) = split_coord(coord)?;
let resolved = prod_ap
.iter()
.chain(test_ap)
.find(|c| c.group_id == group_id && c.artifact_id == artifact_id)
.ok_or_else(|| {
anyhow::anyhow!(
"on-compile-classpath annotation processor '{coord}' is not declared in \
[annotation-processors] or [test-annotation-processors]"
)
})?;
deps.push(MavenDependency {
group_id,
artifact_id,
version: Some(resolved.version.clone()),
scope: Some("provided".to_string()),
exclusions: vec![],
});
}
Ok(deps)
}
fn build_compiler_plugin(
desc: &Descriptor,
layout: &MavenLayout,
prod_ap: &[MavenCoordinate],
test_ap: &[MavenCoordinate],
has_kotlin: bool,
) -> Result<Option<MavenPlugin>> {
let preview = desc.java.preview_enabled();
let mut compile_config = Vec::new();
if !layout.colocated_test_excludes.is_empty() {
compile_config.push(excludes_node(&layout.colocated_test_excludes));
}
if !prod_ap.is_empty() {
compile_config.push(annotation_processor_paths_node(prod_ap));
}
let prod_args = compiler_args(preview, &desc.flat_ap_options());
if !prod_args.is_empty() {
compile_config.push(compiler_args_node(&prod_args));
}
let mut test_compile_config = Vec::new();
if !test_ap.is_empty() {
test_compile_config.push(annotation_processor_paths_node(test_ap));
}
let test_args = compiler_args(preview, &desc.flat_test_ap_options());
if !test_args.is_empty() {
test_compile_config.push(compiler_args_node(&test_args));
}
let executions = compiler_executions(compile_config, test_compile_config, has_kotlin);
if executions.is_empty() {
return Ok(None);
}
Ok(Some(MavenPlugin {
artifact_id: "maven-compiler-plugin".to_string(),
version: plugin_versions::COMPILER.to_string(),
executions,
..Default::default()
}))
}
fn compiler_executions(
compile_config: Vec<XmlNode>,
test_compile_config: Vec<XmlNode>,
has_kotlin: bool,
) -> Vec<MavenExecution> {
if !has_kotlin {
let mut executions = Vec::new();
if !compile_config.is_empty() {
executions.push(MavenExecution {
id: Some("default-compile".to_string()),
configuration: compile_config,
..Default::default()
});
}
if !test_compile_config.is_empty() {
executions.push(MavenExecution {
id: Some("default-testCompile".to_string()),
configuration: test_compile_config,
..Default::default()
});
}
return executions;
}
vec![
MavenExecution { id: Some("default-compile".to_string()), phase: Some("none".to_string()), ..Default::default() },
MavenExecution { id: Some("default-testCompile".to_string()), phase: Some("none".to_string()), ..Default::default() },
MavenExecution {
id: Some("java-compile".to_string()),
phase: Some("compile".to_string()),
goals: vec!["compile".to_string()],
configuration: compile_config,
},
MavenExecution {
id: Some("java-test-compile".to_string()),
phase: Some("test-compile".to_string()),
goals: vec!["testCompile".to_string()],
configuration: test_compile_config,
},
]
}
fn compiler_args(preview: bool, options: &[(String, String)]) -> Vec<String> {
let mut args = Vec::new();
if preview {
args.push("--enable-preview".to_string());
}
for (key, value) in options {
args.push(format!("-A{key}={value}"));
}
args
}
fn excludes_node(patterns: &[String]) -> XmlNode {
XmlNode::element("excludes", patterns.iter().map(|p| XmlNode::text("exclude", p.clone())).collect())
}
fn annotation_processor_paths_node(coords: &[MavenCoordinate]) -> XmlNode {
XmlNode::element(
"annotationProcessorPaths",
coords
.iter()
.map(|c| {
XmlNode::element(
"path",
vec![
XmlNode::text("groupId", c.group_id.clone()),
XmlNode::text("artifactId", c.artifact_id.clone()),
XmlNode::text("version", c.version.clone()),
],
)
})
.collect(),
)
}
fn compiler_args_node(args: &[String]) -> XmlNode {
XmlNode::element("compilerArgs", args.iter().map(|a| XmlNode::text("arg", a.clone())).collect())
}
fn build_kotlin_plugin(desc: &Descriptor, layout: &MavenLayout) -> MavenPlugin {
MavenPlugin {
group_id: Some(KOTLIN_GROUP_ID.to_string()),
artifact_id: KOTLIN_PLUGIN_ARTIFACT_ID.to_string(),
version: desc.kotlin.version().to_string(),
executions: vec![
MavenExecution {
id: Some("compile".to_string()),
goals: vec!["compile".to_string()],
configuration: vec![source_dirs_node(&layout.src_roots)],
..Default::default()
},
MavenExecution {
id: Some("test-compile".to_string()),
goals: vec!["test-compile".to_string()],
configuration: vec![source_dirs_node(&layout.test_roots)],
..Default::default()
},
],
..Default::default()
}
}
fn source_dirs_node(roots: &[PathBuf]) -> XmlNode {
XmlNode::element("sourceDirs", roots.iter().map(|r| XmlNode::text("sourceDir", path_to_maven(r))).collect())
}
fn kotlin_stdlib_dependency(desc: &Descriptor) -> MavenDependency {
MavenDependency {
group_id: KOTLIN_GROUP_ID.to_string(),
artifact_id: KOTLIN_STDLIB_ARTIFACT_ID.to_string(),
version: Some(desc.kotlin.version().to_string()),
scope: None,
exclusions: vec![],
}
}
fn build_gmavenplus_plugin(project_root: &Path, layout: &MavenLayout) -> MavenPlugin {
let mut configuration = vec![XmlNode::text("targetBytecode", "${maven.compiler.release}")];
let source_filesets = groovy_filesets(project_root, &layout.src_roots, &layout.colocated_test_excludes);
if !source_filesets.is_empty() {
configuration.push(XmlNode::element("sources", source_filesets));
}
let test_filesets = groovy_filesets(project_root, &layout.test_roots, &[]);
if !test_filesets.is_empty() {
configuration.push(XmlNode::element("testSources", test_filesets));
}
MavenPlugin {
group_id: Some(GMAVENPLUS_GROUP_ID.to_string()),
artifact_id: GMAVENPLUS_ARTIFACT_ID.to_string(),
version: plugin_versions::GMAVENPLUS.to_string(),
configuration,
executions: vec![MavenExecution {
goals: [
"addSources",
"addTestSources",
"generateStubs",
"compile",
"generateTestStubs",
"compileTests",
"removeStubs",
"removeTestStubs",
]
.iter()
.map(|g| g.to_string())
.collect(),
..Default::default()
}],
..Default::default()
}
}
fn groovy_filesets(project_root: &Path, roots: &[PathBuf], excludes: &[String]) -> Vec<XmlNode> {
roots
.iter()
.filter(|root| dir_has_extension(project_root, root, "groovy"))
.map(|root| {
let mut children = vec![
XmlNode::text("directory", path_to_maven(root)),
XmlNode::element("includes", vec![XmlNode::text("include", "**/*.groovy")]),
];
if !excludes.is_empty() {
children.push(excludes_node(excludes));
}
XmlNode::element("fileset", children)
})
.collect()
}
#[derive(serde::Deserialize, Debug)]
struct ProtobufSyncConfig {
version: String,
#[serde(rename = "sourceDir", default = "default_proto_source_dir")]
source_dir: String,
#[serde(default)]
grpc: bool,
#[serde(rename = "grpcVersion")]
grpc_version: Option<String>,
}
fn default_proto_source_dir() -> String {
"proto".to_string()
}
#[derive(serde::Deserialize, Debug)]
struct OpenApiSyncConfig {
version: String,
#[serde(rename = "specFile")]
spec_file: String,
#[serde(rename = "generatorName")]
generator_name: String,
#[serde(rename = "apiPackage")]
api_package: Option<String>,
#[serde(rename = "modelPackage")]
model_package: Option<String>,
#[serde(rename = "invokerPackage")]
invoker_package: Option<String>,
#[serde(rename = "outputDir", default = "default_openapi_output_dir")]
output_dir: String,
#[serde(rename = "globalProperties", default)]
global_properties: BTreeMap<String, String>,
#[serde(rename = "additionalProperties", default)]
additional_properties: BTreeMap<String, String>,
}
fn default_openapi_output_dir() -> String {
"target/generated-sources/openapi".to_string()
}
fn string_map_node(tag: &str, map: &BTreeMap<String, String>) -> XmlNode {
XmlNode::element(tag, map.iter().map(|(k, v)| XmlNode::text(k.clone(), v.clone())).collect())
}
fn build_protobuf_plugin(cfg: &ProtobufSyncConfig) -> MavenPlugin {
let mut configuration = vec![
XmlNode::text("protoc", cfg.version.clone()),
XmlNode::element(
"sourceDirectories",
vec![XmlNode::text("sourceDirectory", cfg.source_dir.clone())],
),
];
if cfg.grpc {
let grpc_version = cfg.grpc_version.as_deref().unwrap_or("1.60.0");
configuration.push(XmlNode::element(
"binaryMavenPlugins",
vec![XmlNode::element(
"binaryMavenPlugin",
vec![
XmlNode::text("groupId", "io.grpc"),
XmlNode::text("artifactId", "protoc-gen-grpc-java"),
XmlNode::text("version", grpc_version),
],
)],
));
}
MavenPlugin {
group_id: Some(PROTOBUF_MAVEN_GROUP_ID.to_string()),
artifact_id: PROTOBUF_MAVEN_ARTIFACT_ID.to_string(),
version: plugin_versions::PROTOBUF_MAVEN.to_string(),
executions: vec![MavenExecution {
goals: vec!["generate".to_string()],
configuration,
..Default::default()
}],
..Default::default()
}
}
fn build_openapi_plugin(cfg: &OpenApiSyncConfig) -> MavenPlugin {
let mut exe_config = vec![
XmlNode::text("inputSpec", format!("${{project.basedir}}/{}", cfg.spec_file)),
XmlNode::text("generatorName", cfg.generator_name.clone()),
XmlNode::text("output", cfg.output_dir.clone()),
];
if let Some(pkg) = &cfg.api_package {
exe_config.push(XmlNode::text("apiPackage", pkg.clone()));
}
if let Some(pkg) = &cfg.model_package {
exe_config.push(XmlNode::text("modelPackage", pkg.clone()));
}
if let Some(pkg) = &cfg.invoker_package {
exe_config.push(XmlNode::text("invokerPackage", pkg.clone()));
}
if !cfg.additional_properties.is_empty() {
exe_config.push(string_map_node("configOptions", &cfg.additional_properties));
}
if !cfg.global_properties.is_empty() {
exe_config.push(string_map_node("globalProperties", &cfg.global_properties));
}
MavenPlugin {
group_id: Some(OPENAPI_GENERATOR_GROUP_ID.to_string()),
artifact_id: OPENAPI_GENERATOR_ARTIFACT_ID.to_string(),
version: cfg.version.clone(),
executions: vec![MavenExecution {
goals: vec!["generate".to_string()],
configuration: exe_config,
..Default::default()
}],
..Default::default()
}
}
fn build_source_generator_plugins(desc: &Descriptor) -> Result<Vec<MavenPlugin>> {
let mut plugins = Vec::new();
for (name, raw) in &desc.plugins {
match name.as_str() {
"protobuf" => {
let cfg: ProtobufSyncConfig = raw.clone().try_into().with_context(|| {
format!("[plugin.protobuf]: invalid configuration — {raw}")
})?;
plugins.push(build_protobuf_plugin(&cfg));
}
"openapi" => {
let cfg: OpenApiSyncConfig = raw.clone().try_into().with_context(|| {
format!("[plugin.openapi]: invalid configuration — {raw}")
})?;
plugins.push(build_openapi_plugin(&cfg));
}
_ => {}
}
}
Ok(plugins)
}
fn groovy_dependency(desc: &Descriptor) -> MavenDependency {
MavenDependency {
group_id: GROOVY_GROUP_ID.to_string(),
artifact_id: GROOVY_ARTIFACT_ID.to_string(),
version: Some(desc.groovy.version().to_string()),
scope: None,
exclusions: vec![],
}
}
fn spock_core_dependency(desc: &Descriptor) -> MavenDependency {
MavenDependency {
group_id: SPOCK_GROUP_ID.to_string(),
artifact_id: SPOCK_CORE_ARTIFACT_ID.to_string(),
version: Some(desc.spock.version().to_string()),
scope: Some("test".to_string()),
exclusions: vec![],
}
}
pub mod surefire_includes {
pub const DEFAULT: &[&str] = &["**/Test*.java", "**/*Test.java", "**/*Tests.java", "**/*TestCase.java"];
pub const SPOCK_EXTRA: &str = "**/*Spec.java";
}
fn surefire_arg_line(desc: &Descriptor) -> Option<String> {
let mut args = Vec::new();
if desc.java.preview_enabled() {
args.push("--enable-preview".to_string());
}
for coord in desc.test_dep_java_agent_coords() {
args.push(format!("-javaagent:${{{coord}:jar}}"));
}
if args.is_empty() {
return None;
}
if desc.test.coverage_enabled() {
args.insert(0, "@{argLine}".to_string());
}
Some(args.join(" "))
}
fn build_surefire_plugin(desc: &Descriptor, is_modular: bool) -> MavenPlugin {
let mut configuration = Vec::new();
if desc.spock.enabled() {
let includes = surefire_includes::DEFAULT
.iter()
.copied()
.chain(std::iter::once(surefire_includes::SPOCK_EXTRA))
.map(|pattern| XmlNode::text("include", pattern))
.collect();
configuration.push(XmlNode::element("includes", includes));
}
if let Some(arg_line) = surefire_arg_line(desc) {
configuration.push(XmlNode::text("argLine", arg_line));
}
if is_modular {
configuration.push(XmlNode::text("useModulePath", "false".to_string()));
}
MavenPlugin {
artifact_id: "maven-surefire-plugin".to_string(),
version: plugin_versions::SUREFIRE.to_string(),
configuration,
..Default::default()
}
}
fn build_jacoco_plugin() -> MavenPlugin {
MavenPlugin {
group_id: Some("org.jacoco".to_string()),
artifact_id: "jacoco-maven-plugin".to_string(),
version: plugin_versions::JACOCO.to_string(),
executions: vec![
MavenExecution { goals: vec!["prepare-agent".to_string()], ..Default::default() },
MavenExecution {
id: Some("report".to_string()),
phase: Some("test".to_string()),
goals: vec!["report".to_string()],
..Default::default()
},
],
..Default::default()
}
}
fn build_jar_plugin(main_class: Option<&str>, populate_libs: bool) -> MavenPlugin {
let mut manifest = Vec::new();
if let Some(main_class) = main_class {
manifest.push(XmlNode::text("mainClass", main_class.to_string()));
}
if populate_libs {
manifest.push(XmlNode::text("addClasspath", "true".to_string()));
manifest.push(XmlNode::text("classpathPrefix", "libs/".to_string()));
}
let mut archive = vec![XmlNode::text("addMavenDescriptor", "false".to_string())];
if !manifest.is_empty() {
archive.push(XmlNode::element("manifest", manifest));
}
MavenPlugin {
artifact_id: "maven-jar-plugin".to_string(),
version: plugin_versions::JAR.to_string(),
configuration: vec![XmlNode::element("archive", archive)],
..Default::default()
}
}
fn build_resources_plugin(filtering: &ResourceFiltering) -> Option<MavenPlugin> {
let delimiter = filtering.delimiter.as_ref()?;
let config = vec![
XmlNode::element(
"delimiters",
vec![XmlNode::text("delimiter", delimiter.clone())],
),
XmlNode::text("useDefaultDelimiters", "false".to_string()),
];
Some(MavenPlugin {
artifact_id: "maven-resources-plugin".to_string(),
version: plugin_versions::RESOURCES.to_string(),
configuration: config,
..Default::default()
})
}
fn dependency_plugin_executions(desc: &Descriptor, populate_libs: bool) -> Vec<MavenExecution> {
let mut executions = Vec::new();
if !desc.test_dep_java_agent_coords().is_empty() {
executions.push(MavenExecution { goals: vec!["properties".to_string()], ..Default::default() });
}
if populate_libs {
executions.push(MavenExecution {
id: Some("copy-dependencies".to_string()),
phase: Some("package".to_string()),
goals: vec!["copy-dependencies".to_string()],
configuration: vec![
XmlNode::text("outputDirectory", "${project.build.directory}/libs".to_string()),
XmlNode::text("includeScope", "runtime".to_string()),
],
});
}
executions
}
fn build_dependency_plugin(executions: Vec<MavenExecution>) -> Option<MavenPlugin> {
if executions.is_empty() {
return None;
}
Some(MavenPlugin {
artifact_id: "maven-dependency-plugin".to_string(),
version: plugin_versions::DEPENDENCY.to_string(),
executions,
..Default::default()
})
}
const JUNIT_STANDALONE_GROUP_ID: &str = "org.junit.platform";
const JUNIT_STANDALONE_ARTIFACT_ID: &str = "junit-platform-console-standalone";
const KNOWN_TEST_ENGINE_COORDS: &[&str] = &[
"org.junit.jupiter:junit-jupiter",
"org.junit.jupiter:junit-jupiter-engine",
"org.junit.vintage:junit-vintage-engine",
"org.spockframework:spock-core",
];
fn has_test_engine_dependency(desc: &Descriptor) -> bool {
desc.spock.enabled()
|| desc
.dependencies
.keys()
.chain(desc.test_dependencies.keys())
.any(|coord| KNOWN_TEST_ENGINE_COORDS.contains(&coord.as_str()))
}
fn junit_standalone_dependency(desc: &Descriptor) -> Option<MavenDependency> {
if has_test_engine_dependency(desc) {
return None;
}
Some(MavenDependency {
group_id: JUNIT_STANDALONE_GROUP_ID.to_string(),
artifact_id: JUNIT_STANDALONE_ARTIFACT_ID.to_string(),
version: Some(desc.test.junit_platform_version().to_string()),
scope: Some("test".to_string()),
exclusions: vec![],
})
}
fn build_helper_plugins(layout: &MavenLayout) -> Vec<MavenPlugin> {
let extra_src = layout.src_roots.iter().skip(1);
let extra_test = layout.test_roots.iter().skip(1);
let mut executions = Vec::new();
let src_sources: Vec<XmlNode> = extra_src.map(|r| XmlNode::text("source", path_to_maven(r))).collect();
if !src_sources.is_empty() {
executions.push(MavenExecution {
id: Some("add-source".to_string()),
phase: Some("generate-sources".to_string()),
goals: vec!["add-source".to_string()],
configuration: vec![XmlNode::element("sources", src_sources)],
});
}
let test_sources: Vec<XmlNode> = extra_test.map(|r| XmlNode::text("source", path_to_maven(r))).collect();
if !test_sources.is_empty() {
executions.push(MavenExecution {
id: Some("add-test-source".to_string()),
phase: Some("generate-test-sources".to_string()),
goals: vec!["add-test-source".to_string()],
configuration: vec![XmlNode::element("sources", test_sources)],
});
}
if executions.is_empty() {
return Vec::new();
}
vec![MavenPlugin {
group_id: Some(BUILD_HELPER_GROUP_ID.to_string()),
artifact_id: BUILD_HELPER_ARTIFACT_ID.to_string(),
version: plugin_versions::BUILD_HELPER.to_string(),
executions,
..Default::default()
}]
}
fn path_to_maven(p: &Path) -> String {
p.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
const SHADE_SERVICES_TRANSFORMER: &str = "org.apache.maven.plugins.shade.resource.ServicesResourceTransformer";
const SHADE_MANIFEST_TRANSFORMER: &str = "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer";
fn build_shade_plugin(desc: &Descriptor, main_class: Option<&str>) -> Option<MavenPlugin> {
if !desc.fat_jar.enabled {
return None;
}
let mut configuration = vec![
XmlNode::text("shadedClassifierName", "fat"),
XmlNode::text("shadedArtifactAttached", "true"),
shade_transformers_node(main_class),
];
configuration.extend(shade_artifact_set_node(desc));
configuration.extend(shade_relocations_node(desc));
Some(MavenPlugin {
artifact_id: "maven-shade-plugin".to_string(),
version: plugin_versions::SHADE.to_string(),
executions: vec![MavenExecution {
phase: Some("package".to_string()),
goals: vec!["shade".to_string()],
configuration,
..Default::default()
}],
..Default::default()
})
}
fn shade_transformers_node(main_class: Option<&str>) -> XmlNode {
let mut transformers = vec![XmlNode::element_with_attr("transformer", ("implementation", SHADE_SERVICES_TRANSFORMER), vec![])];
if let Some(main_class) = main_class {
transformers.push(XmlNode::element_with_attr(
"transformer",
("implementation", SHADE_MANIFEST_TRANSFORMER),
vec![XmlNode::text("mainClass", main_class.to_string())],
));
}
XmlNode::element("transformers", transformers)
}
fn shade_artifact_set_node(desc: &Descriptor) -> Option<XmlNode> {
let shade_all = desc.fat_jar.shade_all;
let mut coords: Vec<String> =
desc.dependencies.iter().filter(|(_, dep)| dep.should_shade(shade_all) != shade_all).map(|(coord, _)| coord.clone()).collect();
if coords.is_empty() {
return None;
}
coords.sort();
let list = if shade_all { excludes_node(&coords) } else { includes_node(&coords) };
Some(XmlNode::element("artifactSet", vec![list]))
}
fn includes_node(patterns: &[String]) -> XmlNode {
XmlNode::element("includes", patterns.iter().map(|p| XmlNode::text("include", p.clone())).collect())
}
fn shade_relocations_node(desc: &Descriptor) -> Option<XmlNode> {
let shade_all = desc.fat_jar.shade_all;
let mut relocations: Vec<&Relocation> = desc.fat_jar.relocations.iter().collect();
for dep in desc.dependencies.values() {
if dep.should_shade(shade_all) {
relocations.extend(dep.relocations());
}
}
if relocations.is_empty() {
return None;
}
Some(XmlNode::element("relocations", relocations.into_iter().map(relocation_node).collect()))
}
fn relocation_node(reloc: &Relocation) -> XmlNode {
let mut children = vec![XmlNode::text("pattern", reloc.from.clone()), XmlNode::text("shadedPattern", reloc.to.clone())];
if !reloc.excludes.is_empty() {
children.push(excludes_node(&reloc.excludes));
}
XmlNode::element("relocation", children)
}
type XmlWriter<'a> = Writer<Cursor<&'a mut Vec<u8>>>;
pub fn render(project: &MavenProject, fingerprint_hex: &str) -> Result<String> {
let mut buf = Vec::new();
let mut w = Writer::new_with_indent(Cursor::new(&mut buf), b' ', 2);
w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
let comment = format!(" {GENERATED_MARKER}\n {FINGERPRINT_PREFIX}{fingerprint_hex} ");
w.write_event(Event::Comment(BytesText::from_escaped(comment)))?;
let mut project_el = BytesStart::new("project");
project_el.push_attribute(("xmlns", "http://maven.apache.org/POM/4.0.0"));
project_el.push_attribute(("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"));
project_el.push_attribute((
"xsi:schemaLocation",
"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd",
));
w.write_event(Event::Start(project_el))?;
text_elem(&mut w, "modelVersion", "4.0.0")?;
text_elem(&mut w, "groupId", &project.group_id)?;
text_elem(&mut w, "artifactId", &project.artifact_id)?;
text_elem(&mut w, "version", &project.version)?;
text_elem(&mut w, "packaging", &project.packaging)?;
text_elem(&mut w, "name", &project.artifact_id)?;
write_modules(&mut w, &project.modules)?;
write_properties(&mut w, &project.properties)?;
write_dependency_management(&mut w, &project.managed_dependencies)?;
write_dependencies(&mut w, &project.dependencies)?;
write_repositories(&mut w, &project.repositories)?;
write_build(&mut w, project)?;
w.write_event(Event::End(BytesEnd::new("project")))?;
let mut out = String::from_utf8(buf)?;
out.push('\n');
Ok(out)
}
fn text_elem(w: &mut XmlWriter<'_>, name: &str, text: &str) -> Result<()> {
w.create_element(name).write_text_content(BytesText::new(text))?;
Ok(())
}
fn write_modules(w: &mut XmlWriter<'_>, modules: &[String]) -> Result<()> {
if modules.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("modules")))?;
for module in modules {
text_elem(w, "module", module)?;
}
w.write_event(Event::End(BytesEnd::new("modules")))?;
Ok(())
}
fn write_properties(w: &mut XmlWriter<'_>, props: &[(String, String)]) -> Result<()> {
if props.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("properties")))?;
for (key, value) in props {
text_elem(w, key, value)?;
}
w.write_event(Event::End(BytesEnd::new("properties")))?;
Ok(())
}
fn write_dependency_management(w: &mut XmlWriter<'_>, managed: &[MavenManagedDependency]) -> Result<()> {
if managed.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("dependencyManagement")))?;
w.write_event(Event::Start(BytesStart::new("dependencies")))?;
for dep in managed {
w.write_event(Event::Start(BytesStart::new("dependency")))?;
text_elem(w, "groupId", &dep.group_id)?;
text_elem(w, "artifactId", &dep.artifact_id)?;
text_elem(w, "version", &dep.version)?;
if dep.is_import {
text_elem(w, "type", "pom")?;
text_elem(w, "scope", "import")?;
}
w.write_event(Event::End(BytesEnd::new("dependency")))?;
}
w.write_event(Event::End(BytesEnd::new("dependencies")))?;
w.write_event(Event::End(BytesEnd::new("dependencyManagement")))?;
Ok(())
}
fn write_dependencies(w: &mut XmlWriter<'_>, deps: &[MavenDependency]) -> Result<()> {
if deps.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("dependencies")))?;
for dep in deps {
w.write_event(Event::Start(BytesStart::new("dependency")))?;
text_elem(w, "groupId", &dep.group_id)?;
text_elem(w, "artifactId", &dep.artifact_id)?;
if let Some(version) = &dep.version {
text_elem(w, "version", version)?;
}
if let Some(scope) = &dep.scope {
text_elem(w, "scope", scope)?;
}
if !dep.exclusions.is_empty() {
w.write_event(Event::Start(BytesStart::new("exclusions")))?;
for (group_id, artifact_id) in &dep.exclusions {
w.write_event(Event::Start(BytesStart::new("exclusion")))?;
text_elem(w, "groupId", group_id)?;
text_elem(w, "artifactId", artifact_id)?;
w.write_event(Event::End(BytesEnd::new("exclusion")))?;
}
w.write_event(Event::End(BytesEnd::new("exclusions")))?;
}
w.write_event(Event::End(BytesEnd::new("dependency")))?;
}
w.write_event(Event::End(BytesEnd::new("dependencies")))?;
Ok(())
}
fn write_repositories(w: &mut XmlWriter<'_>, repos: &[MavenRepository]) -> Result<()> {
if repos.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("repositories")))?;
for repo in repos {
w.write_event(Event::Start(BytesStart::new("repository")))?;
text_elem(w, "id", &repo.id)?;
text_elem(w, "name", &repo.name)?;
text_elem(w, "url", &repo.url)?;
w.write_event(Event::End(BytesEnd::new("repository")))?;
}
w.write_event(Event::End(BytesEnd::new("repositories")))?;
Ok(())
}
fn write_build(w: &mut XmlWriter<'_>, project: &MavenProject) -> Result<()> {
let layout = &project.layout;
let source_directory = layout
.src_roots
.first()
.filter(|r| r.as_path() != Path::new("src/main/java"));
let test_source_directory = layout
.test_roots
.first()
.filter(|r| r.as_path() != Path::new("src/test/java"));
let filtering = &project.resource_filtering;
let has_build_content = source_directory.is_some()
|| test_source_directory.is_some()
|| layout.resources.is_some()
|| layout.test_resources.is_some()
|| filtering.active
|| !project.plugins.is_empty();
if !has_build_content {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("build")))?;
if let Some(dir) = source_directory {
text_elem(w, "sourceDirectory", &path_to_maven(dir))?;
}
if let Some(dir) = test_source_directory {
text_elem(w, "testSourceDirectory", &path_to_maven(dir))?;
}
if filtering.active {
write_filtered_resources(w, "resources", "resource", &filtering.main)?;
write_filtered_resources(w, "testResources", "testResource", &filtering.test)?;
write_filters(w, &filtering.filters)?;
} else {
write_resource_block(w, "resources", "resource", layout.resources.as_deref())?;
write_resource_block(w, "testResources", "testResource", layout.test_resources.as_deref())?;
}
write_plugins(w, &project.plugins)?;
w.write_event(Event::End(BytesEnd::new("build")))?;
Ok(())
}
fn write_filtered_resources(
w: &mut XmlWriter<'_>,
outer: &str,
inner: &str,
entries: &[MavenResourceEntry],
) -> Result<()> {
if entries.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new(outer)))?;
for entry in entries {
write_one_filtered_resource(w, inner, entry)?;
}
w.write_event(Event::End(BytesEnd::new(outer)))?;
Ok(())
}
fn write_one_filtered_resource(
w: &mut XmlWriter<'_>,
inner: &str,
entry: &MavenResourceEntry,
) -> Result<()> {
w.write_event(Event::Start(BytesStart::new(inner)))?;
text_elem(w, "directory", &entry.directory)?;
if entry.filtering {
text_elem(w, "filtering", "true")?;
}
write_pattern_list(w, "includes", "include", &entry.includes)?;
write_pattern_list(w, "excludes", "exclude", &entry.excludes)?;
w.write_event(Event::End(BytesEnd::new(inner)))?;
Ok(())
}
fn write_pattern_list(
w: &mut XmlWriter<'_>,
outer: &str,
inner: &str,
patterns: &[String],
) -> Result<()> {
if patterns.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new(outer)))?;
for pattern in patterns {
text_elem(w, inner, pattern)?;
}
w.write_event(Event::End(BytesEnd::new(outer)))?;
Ok(())
}
fn write_filters(w: &mut XmlWriter<'_>, filters: &[String]) -> Result<()> {
write_pattern_list(w, "filters", "filter", filters)
}
fn build_resource_filtering(desc: &Descriptor, layout: &MavenLayout) -> Result<ResourceFiltering> {
ensure_maven_representable(&desc.resources, "resources")?;
ensure_maven_representable(&desc.test_resources, "test-resources")?;
if !desc.resources.is_active() && !desc.test_resources.is_active() {
return Ok(ResourceFiltering::default());
}
let main = scope_resource_entries(&desc.resources, layout.resources.as_deref());
let test = scope_resource_entries(&desc.test_resources, layout.test_resources.as_deref());
let delimiter = resolve_maven_delimiter(desc)?;
let filters = merge_filter_files(desc);
Ok(ResourceFiltering { active: true, main, test, filters, delimiter })
}
fn ensure_maven_representable(scope: &Resources, label: &str) -> Result<()> {
if scope.filter.len() > 1 {
anyhow::bail!(
"curie maven sync supports a single `substitute` filter stage per scope; \
[{}] has {} stages — Maven cannot reproduce a chained pipeline. \
Remove stages or skip maven sync for this project.",
label,
scope.filter.len()
);
}
if let Some(stage) = scope.filter.first() {
if stage.engine != crate::descriptor::Engine::Substitute {
anyhow::bail!(
"curie maven sync supports only the `substitute` filter engine; \
[{}] uses the `{}` engine — Maven has no equivalent.",
label,
stage.engine_name()
);
}
}
Ok(())
}
fn scope_resource_entries(scope: &Resources, auto: Option<&Path>) -> Vec<MavenResourceEntry> {
let directories = scope_directories(scope, auto);
let stage = scope.filter.first();
directories
.into_iter()
.map(|directory| {
let filtering = stage.map(|s| stage_covers_dir(s, &directory)).unwrap_or(false);
let (includes, excludes) = match (filtering, stage) {
(true, Some(s)) => (s.includes.clone(), s.excludes.clone()),
_ => (Vec::new(), Vec::new()),
};
MavenResourceEntry { directory, filtering, includes, excludes }
})
.collect()
}
fn scope_directories(scope: &Resources, auto: Option<&Path>) -> Vec<String> {
if !scope.directories.is_empty() {
return scope.directories.clone();
}
auto.map(|p| vec![path_to_maven(p)]).into_iter().flatten().collect()
}
fn stage_covers_dir(stage: &crate::descriptor::FilterStage, directory: &str) -> bool {
stage.directories.is_empty() || stage.directories.iter().any(|d| d == directory)
}
fn resolve_maven_delimiter(desc: &Descriptor) -> Result<Option<String>> {
let mut found: Option<Option<String>> = None;
for scope in [&desc.resources, &desc.test_resources] {
if let Some(stage) = scope.filter.first() {
let opts = stage.substitute.clone().unwrap_or_default();
let delim = maven_delimiter_spec(opts.delimiter.begin_token(), opts.delimiter.end_token());
match &found {
Some(prev) if prev != &delim => anyhow::bail!(
"curie maven sync: [resources] and [test-resources] use different \
substitution delimiters, but Maven's resources plugin is configured \
POM-wide — make them match or skip maven sync."
),
_ => found = Some(delim),
}
}
}
Ok(found.flatten())
}
fn maven_delimiter_spec(begin: &str, end: &str) -> Option<String> {
if begin == "${" && end == "}" {
return None;
}
if begin == end {
Some(begin.to_string())
} else {
Some(format!("{}*{}", begin, end))
}
}
fn merge_filter_files(desc: &Descriptor) -> Vec<String> {
let mut out = Vec::new();
for scope in [&desc.resources, &desc.test_resources] {
for file in &scope.filter_files {
if !out.contains(file) {
out.push(file.clone());
}
}
}
out
}
fn merge_resource_properties(desc: &Descriptor) -> Result<Vec<(String, String)>> {
let mut merged: BTreeMap<String, String> = BTreeMap::new();
for scope in [&desc.resources, &desc.test_resources] {
for (key, value) in &scope.properties {
if let Some(existing) = merged.get(key) {
if existing != value {
anyhow::bail!(
"curie maven sync: property '{}' has different values in [resources] \
and [test-resources], but Maven shares one <properties> set across both.",
key
);
}
}
merged.insert(key.clone(), value.clone());
}
}
Ok(merged.into_iter().collect())
}
fn write_resource_block(w: &mut XmlWriter<'_>, outer: &str, inner: &str, dir: Option<&Path>) -> Result<()> {
let Some(dir) = dir else { return Ok(()) };
w.write_event(Event::Start(BytesStart::new(outer)))?;
w.write_event(Event::Start(BytesStart::new(inner)))?;
text_elem(w, "directory", &path_to_maven(dir))?;
w.write_event(Event::End(BytesEnd::new(inner)))?;
w.write_event(Event::End(BytesEnd::new(outer)))?;
Ok(())
}
fn write_plugins(w: &mut XmlWriter<'_>, plugins: &[MavenPlugin]) -> Result<()> {
if plugins.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("plugins")))?;
for plugin in plugins {
write_plugin(w, plugin)?;
}
w.write_event(Event::End(BytesEnd::new("plugins")))?;
Ok(())
}
fn write_plugin(w: &mut XmlWriter<'_>, plugin: &MavenPlugin) -> Result<()> {
w.write_event(Event::Start(BytesStart::new("plugin")))?;
text_elem(w, "groupId", plugin.group_id.as_deref().unwrap_or(DEFAULT_PLUGIN_GROUP_ID))?;
text_elem(w, "artifactId", &plugin.artifact_id)?;
text_elem(w, "version", &plugin.version)?;
write_xml_nodes_in(w, "configuration", &plugin.configuration)?;
write_executions(w, &plugin.executions)?;
w.write_event(Event::End(BytesEnd::new("plugin")))?;
Ok(())
}
fn write_executions(w: &mut XmlWriter<'_>, executions: &[MavenExecution]) -> Result<()> {
if executions.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new("executions")))?;
for exec in executions {
w.write_event(Event::Start(BytesStart::new("execution")))?;
if let Some(id) = &exec.id {
text_elem(w, "id", id)?;
}
if let Some(phase) = &exec.phase {
text_elem(w, "phase", phase)?;
}
if !exec.goals.is_empty() {
w.write_event(Event::Start(BytesStart::new("goals")))?;
for goal in &exec.goals {
text_elem(w, "goal", goal)?;
}
w.write_event(Event::End(BytesEnd::new("goals")))?;
}
write_xml_nodes_in(w, "configuration", &exec.configuration)?;
w.write_event(Event::End(BytesEnd::new("execution")))?;
}
w.write_event(Event::End(BytesEnd::new("executions")))?;
Ok(())
}
fn write_xml_nodes_in(w: &mut XmlWriter<'_>, wrapper: &str, nodes: &[XmlNode]) -> Result<()> {
if nodes.is_empty() {
return Ok(());
}
w.write_event(Event::Start(BytesStart::new(wrapper)))?;
for node in nodes {
write_xml_node(w, node)?;
}
w.write_event(Event::End(BytesEnd::new(wrapper)))?;
Ok(())
}
fn write_xml_node(w: &mut XmlWriter<'_>, node: &XmlNode) -> Result<()> {
match node {
XmlNode::Text(name, value) => text_elem(w, name, value),
XmlNode::Element(name, children) => {
w.write_event(Event::Start(BytesStart::new(name.as_str())))?;
for child in children {
write_xml_node(w, child)?;
}
w.write_event(Event::End(BytesEnd::new(name.as_str())))?;
Ok(())
}
XmlNode::ElementWithAttr(name, (attr_name, attr_value), children) => {
let mut start = BytesStart::new(name.as_str());
start.push_attribute((attr_name.as_str(), attr_value.as_str()));
if children.is_empty() {
w.write_event(Event::Empty(start))?;
} else {
w.write_event(Event::Start(start))?;
for child in children {
write_xml_node(w, child)?;
}
w.write_event(Event::End(BytesEnd::new(name.as_str())))?;
}
Ok(())
}
}
}
pub fn fingerprint(
schema_version: u32,
member_toml: &[u8],
workspace_toml: Option<&[u8]>,
layout: &MavenLayout,
pinned: &[PinnedDependency],
) -> String {
let mut hasher = Sha256::new();
hasher.update(schema_version.to_le_bytes());
hasher.update(member_toml);
if let Some(ws) = workspace_toml {
hasher.update(ws);
}
let mut roots: Vec<String> = Vec::new();
for r in &layout.src_roots {
roots.push(format!("src:{}", path_to_maven(r)));
}
for r in &layout.test_roots {
roots.push(format!("test:{}", path_to_maven(r)));
}
if let Some(r) = &layout.resources {
roots.push(format!("resources:{}", path_to_maven(r)));
}
if let Some(r) = &layout.test_resources {
roots.push(format!("test-resources:{}", path_to_maven(r)));
}
for pattern in &layout.colocated_test_excludes {
roots.push(format!("exclude:{pattern}"));
}
roots.sort();
for r in &roots {
hasher.update(r.as_bytes());
hasher.update(b"\0");
}
let mut pins = pinned.to_vec();
pins.sort();
for pin in &pins {
hasher.update(format!("{}:{}:{}", pin.group_id, pin.artifact_id, pin.version).as_bytes());
hasher.update(b"\0");
}
hex::encode(hasher.finalize())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncOutcome {
UpToDate,
Written,
}
fn sync_project(
pom_path: &Path,
project: &MavenProject,
member_toml: &[u8],
workspace_toml: Option<&[u8]>,
pinned: &[PinnedDependency],
force: bool,
check: bool,
) -> Result<SyncOutcome> {
let fp = fingerprint(SCHEMA_VERSION, member_toml, workspace_toml, &project.layout, pinned);
if let Ok(existing) = std::fs::read_to_string(pom_path) {
match extract_fingerprint(&existing) {
Some(existing_fp) if existing_fp == fp => return Ok(SyncOutcome::UpToDate),
None if !force => return Err(unmarked_pom_error(pom_path)),
_ => {}
}
}
let rendered = render(project, &fp)?;
let unchanged = std::fs::read(pom_path).map(|existing| existing == rendered.as_bytes()).unwrap_or(false);
if unchanged {
return Ok(SyncOutcome::UpToDate);
}
if check {
return Ok(SyncOutcome::Written);
}
if let Some(parent) = pom_path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(pom_path, rendered.as_bytes())
.with_context(|| format!("failed to write {}", pom_path.display()))?;
Ok(SyncOutcome::Written)
}
#[allow(clippy::too_many_arguments)]
pub fn sync_pom(
project_root: &Path,
pom_path: &Path,
desc: &Descriptor,
member_toml: &[u8],
workspace_toml: Option<&[u8]>,
pinned: &[PinnedDependency],
resolved_ap_versions: &BTreeMap<String, String>,
main_class: Option<&str>,
workspace_member_gavs: &BTreeMap<String, MavenCoordinate>,
force: bool,
check: bool,
) -> Result<SyncOutcome> {
let project = build_project(desc, project_root, pinned, resolved_ap_versions, main_class, workspace_member_gavs)?;
sync_project(pom_path, &project, member_toml, workspace_toml, pinned, force, check)
}
pub fn sync_bom_pom(
pom_path: &Path,
desc: &Descriptor,
member_toml: &[u8],
workspace_toml: Option<&[u8]>,
pinned: &[PinnedDependency],
force: bool,
check: bool,
) -> Result<SyncOutcome> {
let project = build_bom_project(desc, pinned)?;
sync_project(pom_path, &project, member_toml, workspace_toml, pinned, force, check)
}
pub fn sync_workspace_pom(
project_root: &Path,
pom_path: &Path,
desc: &Descriptor,
member_toml: &[u8],
workspace_toml: Option<&[u8]>,
force: bool,
check: bool,
) -> Result<SyncOutcome> {
let project = build_workspace_project(desc, project_root)?;
sync_project(pom_path, &project, member_toml, workspace_toml, &[], force, check)
}
fn extract_fingerprint(xml: &str) -> Option<String> {
xml.lines().find_map(|line| line.trim().strip_prefix(FINGERPRINT_PREFIX)).map(|s| s.trim().to_string())
}
fn unmarked_pom_error(pom_path: &Path) -> anyhow::Error {
anyhow::anyhow!(
"{path} already exists and was not generated by `curie maven sync` \
(no curie-maven-fingerprint marker found). To proceed, either:\n\
\x20 - delete {path} and re-run `curie maven sync`,\n\
\x20 - run `curie maven sync --force` to overwrite it, or\n\
\x20 - remove `[maven] sync = true` from Curie.toml to disable sync.",
path = pom_path.display(),
)
}
fn resolve_main_class_for_sync(desc: &Descriptor, project_root: &Path, layout: &MavenLayout) -> Result<Option<String>> {
let app = match desc.application() {
Some(a) => a,
None => return Ok(None),
};
if let Some(declared) = &app.main_class {
return Ok(Some(declared.clone()));
}
let src_roots: Vec<PathBuf> = layout.src_roots.iter().map(|r| project_root.join(r)).collect();
let sources = production_sources(&src_roots);
crate::main_class::detect_main_class_from_source(&src_roots, &sources)
.with_context(|| format!("in project {}", project_root.display()))
.map(Some)
}
fn production_sources(src_roots: &[PathBuf]) -> Vec<PathBuf> {
let mut sources = Vec::new();
for src_root in src_roots {
for entry in walk_files(src_root) {
let name = entry.file_name().to_string_lossy();
let is_test = name.ends_with("Test.java") || name.ends_with("Tests.java") || name.ends_with("Spec.java")
|| name.ends_with("Test.kt") || name.ends_with("Tests.kt") || name.ends_with("Spec.kt");
if !is_test && (name.ends_with(".java") || name.ends_with(".kt")) {
sources.push(entry.into_path());
}
}
}
sources.sort();
sources.dedup();
sources
}
fn workspace_member_gavs(ws: &workspace::Workspace, member_index: usize) -> BTreeMap<String, MavenCoordinate> {
let member = &ws.members[member_index];
member
.descriptor
.workspace_dependencies
.iter()
.enumerate()
.map(|(k, (_label, dep))| {
let target = &ws.members[member.workspace_deps[k]].descriptor;
let gav = MavenCoordinate {
group_id: target.group_id().unwrap_or(GENERATED_GROUP_ID).to_string(),
artifact_id: target.buildable_name().to_string(),
version: target.buildable_version().to_string(),
};
(dep.path.clone(), gav)
})
.collect()
}
fn sync_member_pom(
project_root: &Path,
desc: &Descriptor,
workspace_toml: Option<&[u8]>,
workspace_member_gavs: &BTreeMap<String, MavenCoordinate>,
force: bool,
check: bool,
offline: bool,
) -> Result<(PathBuf, SyncOutcome)> {
let pom_path = project_root.join("pom.xml");
let member_toml = std::fs::read(project_root.join("Curie.toml"))
.with_context(|| format!("failed to read {}", project_root.join("Curie.toml").display()))?;
let pinned = crate::deps::resolve_pinned_dependencies(desc, offline)?;
let outcome = if desc.is_bom() {
sync_bom_pom(&pom_path, desc, &member_toml, workspace_toml, &pinned, force, check)?
} else {
let resolved_ap_versions = crate::deps::resolve_ap_versions_for_sync(desc, offline)?;
let layout = discover_layout(project_root);
let main_class = resolve_main_class_for_sync(desc, project_root, &layout)?;
sync_pom(
project_root, &pom_path, desc, &member_toml, workspace_toml,
&pinned, &resolved_ap_versions, main_class.as_deref(),
workspace_member_gavs, force, check,
)?
};
print_plugin_source_warning(desc);
Ok((pom_path, outcome))
}
fn unrepresented_plugin_names(desc: &Descriptor) -> Vec<&str> {
const REPRESENTED: &[&str] = &["openapi", "protobuf"];
desc.plugins
.keys()
.map(String::as_str)
.filter(|name| !REPRESENTED.contains(name))
.collect()
}
fn print_plugin_source_warning(desc: &Descriptor) {
let names = unrepresented_plugin_names(desc);
if names.is_empty() {
return;
}
let plugins = names.iter().map(|name| format!("plugin.{name}")).collect::<Vec<_>>().join(", ");
let plural = if names.len() == 1 { "" } else { "s" };
crate::parallel::emit(&crate::style::warn(
"Maven",
&format!("{plugins} generated source{plural} not represented in pom.xml"),
));
}
fn sync_one_workspace_member(ws: &workspace::Workspace, member_index: usize, force: bool, check: bool, offline: bool) -> Result<bool> {
let member = &ws.members[member_index];
let gavs = workspace_member_gavs(ws, member_index);
let workspace_dir = member
.path
.parent()
.ok_or_else(|| anyhow::anyhow!("workspace member {} has no parent directory", member.path.display()))?;
let workspace_toml = std::fs::read(workspace_dir.join("Curie.toml"))
.with_context(|| format!("failed to read {}", workspace_dir.join("Curie.toml").display()))?;
let (pom_path, outcome) = sync_member_pom(&member.path, &member.descriptor, Some(&workspace_toml), &gavs, force, check, offline)?;
print_sync_outcome(&pom_path, outcome);
Ok(outcome == SyncOutcome::Written)
}
fn sync_aggregators_recursive(workspace_root: &Path, force: bool, check: bool) -> Result<bool> {
let desc = descriptor::load(workspace_root)
.with_context(|| format!("failed to load workspace at {}", workspace_root.display()))?;
let member_toml = std::fs::read(workspace_root.join("Curie.toml"))
.with_context(|| format!("failed to read {}", workspace_root.join("Curie.toml").display()))?;
let pom_path = workspace_root.join("pom.xml");
let outcome = sync_workspace_pom(workspace_root, &pom_path, &desc, &member_toml, None, force, check)?;
print_sync_outcome(&pom_path, outcome);
let mut any_written = outcome == SyncOutcome::Written;
let workspace_section = desc
.workspace()
.expect("sync_aggregators_recursive: descriptor is not a [workspace]");
for name in &workspace_section.members {
let member_path = workspace_root.join(name);
let member_desc = descriptor::load(&member_path)
.with_context(|| format!("failed to load workspace member \"{name}\""))?;
if member_desc.is_workspace() && sync_aggregators_recursive(&member_path, force, check)? {
any_written = true;
}
}
Ok(any_written)
}
fn print_sync_outcome(pom_path: &Path, outcome: SyncOutcome) {
let rel = path_to_maven(&display_path(pom_path));
match outcome {
SyncOutcome::Written => crate::parallel::emit(&crate::style::active("Maven", &format!("{rel} written"))),
SyncOutcome::UpToDate => crate::parallel::emit(&crate::style::up_to_date_detail("Maven", &rel)),
}
}
fn display_path(pom_path: &Path) -> PathBuf {
let cwd = std::env::current_dir().ok().and_then(|c| c.canonicalize().ok());
let parent = pom_path.parent().and_then(|p| p.canonicalize().ok());
match (cwd, parent) {
(Some(cwd), Some(parent)) => {
let abs = parent.join(pom_path.file_name().unwrap_or_default());
abs.strip_prefix(&cwd).map(Path::to_path_buf).unwrap_or(abs)
}
_ => pom_path.to_path_buf(),
}
}
pub fn run_maven_sync_standalone(project_root: &Path, force: bool, check: bool, offline: bool) -> Result<bool> {
let desc = descriptor::load(project_root)?;
let (pom_path, outcome) = sync_member_pom(project_root, &desc, None, &BTreeMap::new(), force, check, offline)?;
print_sync_outcome(&pom_path, outcome);
Ok(outcome == SyncOutcome::Written)
}
pub fn run_maven_sync_workspace_member(workspace_root: &Path, member_index: usize, force: bool, check: bool, offline: bool) -> Result<bool> {
let ws = workspace::load(workspace_root)?;
sync_one_workspace_member(&ws, member_index, force, check, offline)
}
pub fn run_maven_sync_workspace_subtree(workspace_root: &Path, member_indices: &[usize], force: bool, check: bool, offline: bool) -> Result<bool> {
let ws = workspace::load(workspace_root)?;
let mut any_written = false;
for &member_index in member_indices {
if sync_one_workspace_member(&ws, member_index, force, check, offline)? {
any_written = true;
}
}
Ok(any_written)
}
pub fn run_maven_sync_workspace_root(workspace_root: &Path, force: bool, check: bool, offline: bool) -> Result<bool> {
let ws = workspace::load(workspace_root)?;
let mut any_written = sync_aggregators_recursive(workspace_root, force, check)?;
for member_index in 0..ws.members.len() {
if sync_one_workspace_member(&ws, member_index, force, check, offline)? {
any_written = true;
}
}
Ok(any_written)
}
pub fn sync_for_build(project_root: &Path, desc: &Descriptor, offline: bool) -> Result<()> {
if !desc.maven.sync_enabled() {
return Ok(());
}
let (pom_path, outcome) = sync_member_pom(project_root, desc, None, &BTreeMap::new(), false, false, offline)?;
print_sync_outcome(&pom_path, outcome);
Ok(())
}
pub fn sync_member_for_build(ws: &workspace::Workspace, member_index: usize, offline: bool) -> Result<()> {
if !ws.members[member_index].descriptor.maven.sync_enabled() {
return Ok(());
}
sync_one_workspace_member(ws, member_index, false, false, offline)?;
Ok(())
}
pub fn sync_aggregator_for_build(workspace_root: &Path) -> Result<()> {
let desc = descriptor::load(workspace_root)
.with_context(|| format!("failed to load workspace at {}", workspace_root.display()))?;
if !desc.maven.sync_enabled() {
return Ok(());
}
sync_aggregators_recursive(workspace_root, false, false)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{Application, DescriptorKind, MavenConfig, *};
use std::collections::BTreeMap;
fn minimal_app(name: &str, group_id: Option<&str>) -> Descriptor {
Descriptor {
kind: DescriptorKind::Application(Application {
name: name.to_string(),
version: "1.0.0".to_string(),
group_id: group_id.map(String::from),
main_class: Some("Main".to_string()),
}),
java: Java::default(),
test: Test::default(),
kotlin: Kotlin::default(),
groovy: Groovy::default(),
spock: Spock::default(),
native_image: NativeImage::default(),
docker: Docker::default(),
build_info: BuildInfo::default(),
fat_jar: FatJar::default(),
dependencies: BTreeMap::new(),
test_dependencies: BTreeMap::new(),
repositories: vec![],
bom_imports: BTreeMap::new(),
test_bom_imports: BTreeMap::new(),
inherited_bom_imports: BTreeMap::new(),
inherited_test_bom_imports: BTreeMap::new(),
workspace_dependencies: BTreeMap::new(),
annotation_processors: BTreeMap::new(),
test_annotation_processors: BTreeMap::new(),
inherited_annotation_processors: BTreeMap::new(),
inherited_test_annotation_processors: BTreeMap::new(),
annotation_processor_options: BTreeMap::new(),
test_annotation_processor_options: BTreeMap::new(),
inherited_annotation_processor_options: BTreeMap::new(),
inherited_test_annotation_processor_options: BTreeMap::new(),
publish: PublishConfig::default(),
plugins: BTreeMap::new(),
maven: MavenConfig::default(),
modules: ModulesConfig::default(),
resources: Resources::default(),
test_resources: Resources::default(),
}
}
fn minimal_library(name: &str, group_id: Option<&str>) -> Descriptor {
let mut desc = minimal_app(name, group_id);
desc.kind = DescriptorKind::Library(Library {
name: name.to_string(),
version: "1.0.0".to_string(),
group_id: group_id.map(String::from),
automatic_module_name: None,
});
desc
}
fn minimal_bom(name: &str, group_id: Option<&str>) -> Descriptor {
let mut desc = minimal_app(name, group_id);
desc.kind = DescriptorKind::Bom(Bom {
name: name.to_string(),
version: "1.0.0".to_string(),
group_id: group_id.map(String::from),
});
desc
}
fn minimal_workspace(members: &[&str]) -> Descriptor {
let mut desc = minimal_app("unused", None);
desc.kind = DescriptorKind::Workspace(WorkspaceSection { members: members.iter().map(|m| m.to_string()).collect() });
desc
}
fn write_file(dir: &Path, rel: &str, contents: &str) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
#[test]
fn coordinates_and_groupid_fallback() {
let dir = tempfile::tempdir().unwrap();
let with_group = minimal_app("my-app", Some("com.example"));
let project = build_project(&with_group, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert_eq!(project.group_id, "com.example");
assert_eq!(project.artifact_id, "my-app");
assert_eq!(project.version, "1.0.0");
assert_eq!(project.packaging, "jar");
let without_group = minimal_app("my-app", None);
let project = build_project(&without_group, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert_eq!(project.group_id, GENERATED_GROUP_ID);
}
#[test]
fn properties_include_release_encoding_and_output_timestamp() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.java.release_version = Some("17".to_string());
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<maven.compiler.release>17</maven.compiler.release>"));
assert!(xml.contains("<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>"));
assert!(xml.contains(&format!("<project.build.outputTimestamp>{OUTPUT_TIMESTAMP}</project.build.outputTimestamp>")));
}
#[test]
fn dependencies_get_compile_and_test_scopes_with_exclusions() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.dependencies.insert(
"com.fasterxml.jackson.core:jackson-databind".to_string(),
DependencyValue::Detailed(DependencyDetailed {
version: "2.17.2".to_string(),
repository: None,
java_agent: false,
exclusions: vec!["com.fasterxml.jackson.core:jackson-annotations".to_string()],
shade: None,
relocations: vec![],
allow_version_conflict: false,
}),
);
desc.test_dependencies.insert(
"org.junit.jupiter:junit-jupiter".to_string(),
DependencyValue::Version("5.10.0".to_string()),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let prod = project.dependencies.iter().find(|d| d.artifact_id == "jackson-databind").unwrap();
assert_eq!(prod.scope.as_deref(), Some("compile"));
assert_eq!(prod.exclusions, vec![("com.fasterxml.jackson.core".to_string(), "jackson-annotations".to_string())]);
let test = project.dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
assert_eq!(test.scope.as_deref(), Some("test"));
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<scope>compile</scope>"));
assert!(xml.contains("<scope>test</scope>"));
assert!(xml.contains("<exclusions>"));
assert!(xml.contains("<artifactId>jackson-annotations</artifactId>"));
}
#[test]
fn repositories_entries_become_pom_repositories() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.repositories.push(RepositoryEntry {
id: "shibboleth".to_string(),
name: Some("Shibboleth Releases".to_string()),
url: "https://build.shibboleth.net/nexus/content/repositories/releases/".to_string(),
});
desc.repositories.push(RepositoryEntry {
id: "unnamed".to_string(),
name: None,
url: "https://example.com/repo".to_string(),
});
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert_eq!(project.repositories.len(), 2);
assert_eq!(project.repositories[1].name, "unnamed", "name defaults to id when absent");
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<repositories>"));
assert!(xml.contains("<id>shibboleth</id>"));
assert!(xml.contains("<name>Shibboleth Releases</name>"));
assert!(xml.contains("<url>https://build.shibboleth.net/nexus/content/repositories/releases/</url>"));
assert!(xml.contains("<id>unnamed</id>"));
assert!(xml.contains("<name>unnamed</name>"));
let deps_pos = xml.find("<dependencies>");
let repos_pos = xml.find("<repositories>").unwrap();
let build_pos = xml.find("<build>").unwrap();
if let Some(deps_pos) = deps_pos {
assert!(deps_pos < repos_pos);
}
assert!(repos_pos < build_pos);
}
#[test]
fn no_repositories_entries_omits_repositories_element() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert!(project.repositories.is_empty());
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<repositories>"));
}
#[test]
fn version_blank_dep_emitted_without_version_tag() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.dependencies.insert(
"com.fasterxml.jackson.core:jackson-databind".to_string(),
DependencyValue::Version(String::new()),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let dep = project.dependencies.iter().find(|d| d.artifact_id == "jackson-databind").unwrap();
assert_eq!(dep.version, None);
let xml = render(&project, "deadbeef").unwrap();
let dep_block = xml.split("<dependency>").find(|b| b.contains("jackson-databind")).unwrap();
assert!(!dep_block.contains("<version>"), "blank version must not render <version>: {dep_block}");
}
#[test]
fn bom_imports_member_before_workspace() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.bom_imports.insert("com.example:member-bom".to_string(), "1.0".to_string());
desc.inherited_bom_imports.insert("com.example:workspace-bom".to_string(), "2.0".to_string());
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert_eq!(project.managed_dependencies.len(), 2);
assert_eq!(project.managed_dependencies[0].artifact_id, "member-bom");
assert_eq!(project.managed_dependencies[1].artifact_id, "workspace-bom");
assert!(project.managed_dependencies.iter().all(|d| d.is_import));
let xml = render(&project, "deadbeef").unwrap();
let member_pos = xml.find("member-bom").unwrap();
let workspace_pos = xml.find("workspace-bom").unwrap();
assert!(member_pos < workspace_pos);
}
#[test]
fn pin_transitive_off_by_default_no_pinned_entries() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert!(project.managed_dependencies.is_empty());
assert!(!desc.maven.pin_transitive_enabled());
}
#[test]
fn pin_transitive_emits_sorted_dependency_management_before_bom_imports() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.bom_imports.insert("com.example:member-bom".to_string(), "1.0".to_string());
let pinned = vec![
PinnedDependency { group_id: "org.slf4j".into(), artifact_id: "slf4j-api".into(), version: "2.0.12".into() },
PinnedDependency { group_id: "com.google.guava".into(), artifact_id: "guava".into(), version: "33.2.0-jre".into() },
];
let project = build_project(&desc, dir.path(), &pinned, &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert_eq!(project.managed_dependencies[0].group_id, "com.google.guava");
assert_eq!(project.managed_dependencies[1].group_id, "org.slf4j");
assert!(!project.managed_dependencies[0].is_import);
assert!(!project.managed_dependencies[1].is_import);
assert_eq!(project.managed_dependencies[2].artifact_id, "member-bom");
let xml = render(&project, "deadbeef").unwrap();
let guava_pos = xml.find("guava").unwrap();
let bom_pos = xml.find("member-bom").unwrap();
assert!(guava_pos < bom_pos);
}
#[test]
fn bom_project_renders_packaging_pom() {
let mut desc = minimal_bom("my-bom", Some("com.example"));
desc.bom_imports.insert("com.example:other-bom".to_string(), "2.0".to_string());
desc.dependencies.insert("org.slf4j:slf4j-api".to_string(), DependencyValue::Version("2.0.13".to_string()));
let project = build_bom_project(&desc, &[]).unwrap();
assert_eq!(project.group_id, "com.example");
assert_eq!(project.artifact_id, "my-bom");
assert_eq!(project.version, "1.0.0");
assert_eq!(project.packaging, "pom");
assert!(project.dependencies.is_empty());
assert!(project.modules.is_empty());
assert_eq!(project.managed_dependencies.len(), 2);
let import = project.managed_dependencies.iter().find(|d| d.artifact_id == "other-bom").unwrap();
assert!(import.is_import);
let plain = project.managed_dependencies.iter().find(|d| d.artifact_id == "slf4j-api").unwrap();
assert!(!plain.is_import);
assert_eq!(plain.version, "2.0.13");
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<packaging>pom</packaging>"));
assert_eq!(xml.matches("<dependencies>").count(), 1);
assert!(!xml.contains("<build>"));
}
#[test]
fn bom_project_requires_group_id() {
let desc = minimal_bom("my-bom", None);
let err = build_bom_project(&desc, &[]).unwrap_err().to_string();
assert!(err.contains("groupId"), "got: {err}");
}
#[test]
fn workspace_renders_aggregator_modules_in_order() {
let dir = tempfile::tempdir().unwrap();
let workspace_root = dir.path().join("my-workspace");
std::fs::create_dir_all(&workspace_root).unwrap();
let desc = minimal_workspace(&["b-member", "a-member", "nested-workspace-demo"]);
let project = build_workspace_project(&desc, &workspace_root).unwrap();
assert_eq!(project.group_id, GENERATED_GROUP_ID);
assert_eq!(project.artifact_id, "my-workspace");
assert_eq!(project.version, GENERATED_VERSION);
assert_eq!(project.packaging, "pom");
assert_eq!(project.modules, vec!["b-member", "a-member", "nested-workspace-demo"]);
assert!(project.dependencies.is_empty());
assert!(project.managed_dependencies.is_empty());
assert!(project.plugins.is_empty());
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<packaging>pom</packaging>"));
let b_pos = xml.find("<module>b-member</module>").unwrap();
let a_pos = xml.find("<module>a-member</module>").unwrap();
let nested_pos = xml.find("<module>nested-workspace-demo</module>").unwrap();
assert!(b_pos < a_pos && a_pos < nested_pos, "modules must render in declaration order: {xml}");
assert!(!xml.contains("<dependencyManagement>"));
assert!(!xml.contains("<build>"));
}
#[test]
fn member_pom_has_no_parent_element() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.inherited_bom_imports.insert("com.example:workspace-bom".to_string(), "2.0".to_string());
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<parent>"), "member POMs must be self-contained, with no <parent>: {xml}");
assert!(xml.contains("workspace-bom"), "inherited BOM imports must be materialized into the member POM: {xml}");
}
#[test]
fn workspace_dep_becomes_module_gav() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("app", Some("com.example"));
desc.workspace_dependencies.insert("core".to_string(), WorkspaceDep { path: "../core".to_string(), version: None });
let mut gavs = BTreeMap::new();
gavs.insert(
"../core".to_string(),
MavenCoordinate { group_id: "com.example".to_string(), artifact_id: "core".to_string(), version: "1.0.0".to_string() },
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &gavs).unwrap();
let dep = project.dependencies.iter().find(|d| d.artifact_id == "core").unwrap();
assert_eq!(dep.group_id, "com.example");
assert_eq!(dep.version, Some("1.0.0".to_string()));
assert_eq!(dep.scope, Some("compile".to_string()));
let xml = render(&project, "deadbeef").unwrap();
let dep_block = xml.split("<dependency>").find(|b| b.contains("<artifactId>core</artifactId>")).unwrap();
assert!(dep_block.contains("<groupId>com.example</groupId>"));
assert!(dep_block.contains("<version>1.0.0</version>"));
assert!(dep_block.contains("<scope>compile</scope>"));
}
#[test]
fn workspace_dep_without_resolved_gav_errors() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("app", Some("com.example"));
desc.workspace_dependencies.insert("core".to_string(), WorkspaceDep { path: "../core".to_string(), version: None });
let err = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap_err().to_string();
assert!(err.contains("workspace-dependencies"), "got: {err}");
assert!(err.contains("../core"), "got: {err}");
}
#[test]
fn maven_layout_keeps_defaults() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
write_file(dir.path(), "src/test/java/com/example/HelloTest.java", "package com.example; class HelloTest {}");
let layout = discover_layout(dir.path());
assert_eq!(layout.src_roots, vec![PathBuf::from("src/main/java")]);
assert_eq!(layout.test_roots, vec![PathBuf::from("src/test/java")]);
assert!(layout.colocated_test_excludes.is_empty());
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<sourceDirectory>"));
assert!(!xml.contains("<testSourceDirectory>"));
}
#[test]
fn flat_layout_sets_sourcedir_and_testdir() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/com.example.app/Hello.java", "package com.example.app; class Hello {}");
write_file(dir.path(), "tests/com.example.app/HelloTest.java", "package com.example.app; class HelloTest {}");
let layout = discover_layout(dir.path());
assert_eq!(layout.src_roots, vec![PathBuf::from("src/com.example.app")]);
assert_eq!(layout.test_roots, vec![PathBuf::from("tests/com.example.app")]);
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<sourceDirectory>src/com.example.app</sourceDirectory>"));
assert!(xml.contains("<testSourceDirectory>tests/com.example.app</testSourceDirectory>"));
}
#[test]
fn extra_source_root_becomes_build_helper_add_source() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
write_file(dir.path(), "src/main/kotlin/com/example/World.kt", "package com.example\nclass World");
let layout = discover_layout(dir.path());
assert_eq!(layout.src_roots, vec![PathBuf::from("src/main/java"), PathBuf::from("src/main/kotlin")]);
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<sourceDirectory>"));
assert!(xml.contains("build-helper-maven-plugin"));
assert!(xml.contains("<source>src/main/kotlin</source>"));
assert!(xml.contains("<goal>add-source</goal>"));
}
#[test]
fn colocated_test_patterns_excluded_from_main_compile() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
write_file(dir.path(), "src/main/java/com/example/HelloTest.java", "package com.example; class HelloTest {}");
let layout = discover_layout(dir.path());
assert_eq!(layout.colocated_test_excludes, vec!["**/*Test.java".to_string(), "**/*Tests.java".to_string(), "**/*Spec.java".to_string()]);
assert!(layout.test_roots.contains(&PathBuf::from("src/main/java")));
}
#[test]
fn resources_are_explicit_even_at_default_location() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
write_file(dir.path(), "src/main/resources/app.properties", "a=b");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<resources>"));
assert!(xml.contains("<directory>src/main/resources</directory>"));
}
fn substitute_stage_for(includes: &[&str], directories: &[&str]) -> crate::descriptor::FilterStage {
crate::descriptor::FilterStage {
engine: crate::descriptor::Engine::Substitute,
directories: directories.iter().map(|s| s.to_string()).collect(),
includes: includes.iter().map(|s| s.to_string()).collect(),
excludes: vec![],
substitute: None,
liquid: None,
}
}
#[test]
fn pom_emits_filtering_and_at_delimiter_for_single_substitute_stage() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
write_file(dir.path(), "src/main/resources/app.properties", "v=@project.version@");
let mut desc = minimal_app("my-app", Some("com.example"));
desc.resources.section_present = true;
desc.resources.filter = vec![substitute_stage_for(&["**/*.properties"], &[])];
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<filtering>true</filtering>"), "got: {xml}");
assert!(xml.contains("<include>**/*.properties</include>"), "got: {xml}");
assert!(xml.contains("maven-resources-plugin"), "got: {xml}");
assert!(xml.contains("<delimiter>@</delimiter>"), "got: {xml}");
assert!(xml.contains("<useDefaultDelimiters>false</useDefaultDelimiters>"), "got: {xml}");
}
#[test]
fn pom_emits_resources_and_testresources_per_directory() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let mut desc = minimal_app("my-app", Some("com.example"));
desc.resources.section_present = true;
desc.resources.directories = vec!["src/main/resources".into(), "src/main/config".into()];
desc.test_resources.section_present = true;
desc.test_resources.directories = vec!["src/test/resources".into()];
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<directory>src/main/resources</directory>"), "got: {xml}");
assert!(xml.contains("<directory>src/main/config</directory>"), "got: {xml}");
assert!(xml.contains("<testResources>"), "got: {xml}");
assert!(xml.contains("<directory>src/test/resources</directory>"), "got: {xml}");
assert!(!xml.contains("<filtering>true</filtering>"), "got: {xml}");
}
#[test]
fn maven_sync_errors_on_multiple_stages_in_either_scope() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.test_resources.section_present = true;
desc.test_resources.filter = vec![
substitute_stage_for(&["**/a"], &[]),
substitute_stage_for(&["**/b"], &[]),
];
let err = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new())
.unwrap_err()
.to_string();
assert!(err.contains("single `substitute` filter stage"), "got: {err}");
assert!(err.contains("test-resources"), "got: {err}");
}
#[test]
fn maven_sync_errors_on_liquid_stage() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.resources.section_present = true;
let mut stage = substitute_stage_for(&[], &[]);
stage.engine = crate::descriptor::Engine::Liquid;
desc.resources.filter = vec![stage];
let err = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new())
.unwrap_err()
.to_string();
assert!(err.contains("substitute"), "got: {err}");
}
#[test]
fn pom_emits_properties_and_filters_from_scopes() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let mut desc = minimal_app("my-app", Some("com.example"));
desc.resources.section_present = true;
desc.resources.filter = vec![substitute_stage_for(&["**/*.properties"], &[])];
desc.resources.properties.insert("api.url".into(), "https://x".into());
desc.resources.filter_files = vec!["build.properties".into()];
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<api.url>https://x</api.url>"), "got: {xml}");
assert!(xml.contains("<filter>build.properties</filter>"), "got: {xml}");
}
#[test]
fn annotation_processor_paths_with_resolved_versions() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.annotation_processors.insert(
"com.google.dagger:dagger-compiler".to_string(),
AnnotationProcessor::Version("2.51.1".to_string()),
);
desc.annotation_processors.insert(
"com.example:bom-managed-processor".to_string(),
AnnotationProcessor::Version(String::new()),
);
let mut resolved = BTreeMap::new();
resolved.insert("com.example:bom-managed-processor".to_string(), "1.2.3".to_string());
let project = build_project(&desc, dir.path(), &[], &resolved, None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<id>default-compile</id>"));
assert!(xml.contains("<annotationProcessorPaths>"));
assert!(xml.contains("<groupId>com.google.dagger</groupId>"));
assert!(xml.contains("<artifactId>dagger-compiler</artifactId>"));
assert!(xml.contains("<version>2.51.1</version>"));
assert!(xml.contains("<artifactId>bom-managed-processor</artifactId>"));
assert!(xml.contains("<version>1.2.3</version>"));
}
#[test]
fn unresolved_bom_managed_ap_version_errors() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.annotation_processors.insert(
"com.example:bom-managed-processor".to_string(),
AnnotationProcessor::Version(String::new()),
);
let err = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap_err().to_string();
assert!(err.contains("com.example:bom-managed-processor"), "{err}");
}
#[test]
fn lombok_on_compile_classpath_becomes_provided_dep() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.annotation_processors.insert(
"org.projectlombok:lombok".to_string(),
AnnotationProcessor::Detailed(AnnotationProcessorDetailed {
version: "1.18.34".to_string(),
on_compile_classpath: true,
}),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let dep = project.dependencies.iter().find(|d| d.artifact_id == "lombok").unwrap();
assert_eq!(dep.group_id, "org.projectlombok");
assert_eq!(dep.version.as_deref(), Some("1.18.34"));
assert_eq!(dep.scope.as_deref(), Some("provided"));
let xml = render(&project, "deadbeef").unwrap();
let dep_block = xml.split("<dependency>").find(|b| b.contains("lombok")).unwrap();
assert!(dep_block.contains("<scope>provided</scope>"));
}
#[test]
fn ap_options_become_compiler_args() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.annotation_processors.insert(
"com.google.dagger:dagger-compiler".to_string(),
AnnotationProcessor::Version("2.51.1".to_string()),
);
let mut options = BTreeMap::new();
options.insert("verbose".to_string(), "true".to_string());
desc.annotation_processor_options.insert("dagger".to_string(), options);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<compilerArgs>"));
assert!(xml.contains("<arg>-Adagger.verbose=true</arg>"));
}
#[test]
fn enable_preview_adds_compiler_args_to_compile_and_test_compile() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.java.enable_preview = Some(true);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let compile = xml.split("<execution>").find(|b| b.contains("default-compile")).unwrap();
assert!(compile.contains("<arg>--enable-preview</arg>"));
let test_compile = xml.split("<execution>").find(|b| b.contains("default-testCompile")).unwrap();
assert!(test_compile.contains("<arg>--enable-preview</arg>"));
}
#[test]
fn colocated_test_excludes_applied_to_default_compile() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
write_file(dir.path(), "src/main/java/com/example/HelloTest.java", "package com.example; class HelloTest {}");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let compile = xml.split("<execution>").find(|b| b.contains("default-compile")).unwrap();
assert!(compile.contains("<excludes>"));
assert!(compile.contains("<exclude>**/*Test.java</exclude>"));
assert!(compile.contains("<exclude>**/*Tests.java</exclude>"));
assert!(compile.contains("<exclude>**/*Spec.java</exclude>"));
}
#[test]
fn test_annotation_processor_paths_include_prod_and_test() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.annotation_processors.insert(
"com.google.dagger:dagger-compiler".to_string(),
AnnotationProcessor::Version("2.51.1".to_string()),
);
desc.test_annotation_processors.insert(
"org.mockito:mockito-core".to_string(),
AnnotationProcessor::Version("5.12.0".to_string()),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let compile = xml.split("<execution>").find(|b| b.contains("default-compile")).unwrap();
assert!(compile.contains("<artifactId>dagger-compiler</artifactId>"));
assert!(!compile.contains("<artifactId>mockito-core</artifactId>"));
let test_compile = xml.split("<execution>").find(|b| b.contains("default-testCompile")).unwrap();
assert!(test_compile.contains("<artifactId>dagger-compiler</artifactId>"));
assert!(test_compile.contains("<artifactId>mockito-core</artifactId>"));
}
#[test]
fn kotlin_sources_get_kotlin_plugin_and_stdlib_dependency() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/kotlin/com/example/Hello.kt", "package com.example\nclass Hello");
write_file(dir.path(), "src/test/kotlin/com/example/HelloTest.kt", "package com.example\nclass HelloTest");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let kotlin_plugin = xml.split("<plugin>").find(|b| b.contains("kotlin-maven-plugin")).unwrap();
assert!(kotlin_plugin.contains("<groupId>org.jetbrains.kotlin</groupId>"));
assert!(kotlin_plugin.contains(&format!("<version>{DEFAULT_KOTLIN_VERSION}</version>")));
assert!(kotlin_plugin.contains("<sourceDir>src/main/kotlin</sourceDir>"));
assert!(kotlin_plugin.contains("<sourceDir>src/test/kotlin</sourceDir>"));
let dep = project.dependencies.iter().find(|d| d.artifact_id == "kotlin-stdlib").unwrap();
assert_eq!(dep.group_id, "org.jetbrains.kotlin");
assert_eq!(dep.version.as_deref(), Some(DEFAULT_KOTLIN_VERSION));
assert_eq!(dep.scope, None);
}
#[test]
fn kotlin_sources_rebind_default_compile_executions() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/kotlin/com/example/Hello.kt", "package com.example\nclass Hello");
write_file(dir.path(), "src/test/kotlin/com/example/HelloTest.kt", "package com.example\nclass HelloTest");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let compiler_plugin = xml.split("<plugin>").find(|b| b.contains("maven-compiler-plugin")).unwrap();
let default_compile = compiler_plugin.split("<execution>").find(|b| b.contains("default-compile")).unwrap();
assert!(default_compile.contains("<phase>none</phase>"));
let default_test_compile = compiler_plugin.split("<execution>").find(|b| b.contains("default-testCompile")).unwrap();
assert!(default_test_compile.contains("<phase>none</phase>"));
let java_compile = compiler_plugin.split("<execution>").find(|b| b.contains("java-compile")).unwrap();
assert!(java_compile.contains("<phase>compile</phase>"));
assert!(java_compile.contains("<goal>compile</goal>"));
let java_test_compile = compiler_plugin.split("<execution>").find(|b| b.contains("java-test-compile")).unwrap();
assert!(java_test_compile.contains("<phase>test-compile</phase>"));
assert!(java_test_compile.contains("<goal>testCompile</goal>"));
}
#[test]
fn groovy_sources_get_gmavenplus_plugin_and_dependency() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/groovy/com/example/Hello.groovy", "package com.example\nclass Hello {}");
write_file(dir.path(), "src/test/groovy/com/example/HelloTest.groovy", "package com.example\nclass HelloTest {}");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let gmavenplus = xml.split("<plugin>").find(|b| b.contains("gmavenplus-plugin")).unwrap();
assert!(gmavenplus.contains("<groupId>org.codehaus.gmavenplus</groupId>"));
assert!(gmavenplus.contains(&format!("<version>{}</version>", plugin_versions::GMAVENPLUS)));
for goal in [
"addSources",
"addTestSources",
"generateStubs",
"compile",
"generateTestStubs",
"compileTests",
"removeStubs",
"removeTestStubs",
] {
assert!(gmavenplus.contains(&format!("<goal>{goal}</goal>")), "missing goal {goal}");
}
assert!(gmavenplus.contains("<targetBytecode>${maven.compiler.release}</targetBytecode>"));
assert!(gmavenplus.contains("<directory>src/main/groovy</directory>"));
assert!(gmavenplus.contains("<directory>src/test/groovy</directory>"));
assert!(gmavenplus.contains("<include>**/*.groovy</include>"));
let dep = project.dependencies.iter().find(|d| d.artifact_id == "groovy").unwrap();
assert_eq!(dep.group_id, "org.apache.groovy");
assert_eq!(dep.version.as_deref(), Some(DEFAULT_GROOVY_VERSION));
assert_eq!(dep.scope, None);
}
#[test]
fn groovy_flat_package_layout_gets_gmavenplus_source_filesets() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/com.example/Hello.groovy", "package com.example\nclass Hello {}");
write_file(dir.path(), "tests/com.example/HelloSpec.groovy", "package com.example\nclass HelloSpec {}");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let gmavenplus = xml.split("<plugin>").find(|b| b.contains("gmavenplus-plugin")).unwrap();
assert!(gmavenplus.contains("<directory>src/com.example</directory>"));
assert!(gmavenplus.contains("<directory>tests/com.example</directory>"));
}
#[test]
fn groovy_colocated_spec_excluded_from_production_sources_fileset() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/com.example/Calculator.groovy", "package com.example\nclass Calculator {}");
write_file(dir.path(), "src/com.example/CalculatorSpec.groovy", "package com.example\nclass CalculatorSpec {}");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let gmavenplus = xml.split("<plugin>").find(|b| b.contains("gmavenplus-plugin")).unwrap();
let sources = gmavenplus.split("<sources>").nth(1).unwrap().split("</sources>").next().unwrap();
assert!(sources.contains("<exclude>**/*Spec.groovy</exclude>"));
let test_sources = gmavenplus.split("<testSources>").nth(1).unwrap().split("</testSources>").next().unwrap();
assert!(!test_sources.contains("<excludes>"));
}
#[test]
fn application_with_groovy_and_no_dependencies_populates_libs() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/groovy/com/example/Hello.groovy", "package com.example\nclass Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<addClasspath>true</addClasspath>"));
assert!(xml.contains("<classpathPrefix>libs/</classpathPrefix>"));
assert!(xml.contains("copy-dependencies"));
}
fn shaded_dependency(version: &str, shade: Option<bool>, relocations: Vec<Relocation>) -> DependencyValue {
DependencyValue::Detailed(DependencyDetailed {
version: version.to_string(),
repository: None,
java_agent: false,
exclusions: vec![],
shade,
relocations,
allow_version_conflict: false,
})
}
#[test]
fn fat_jar_maps_to_shade_with_relocations_and_excludes() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.fat_jar = FatJar {
enabled: true,
shade_all: true,
relocations: vec![Relocation {
from: "com.google.common".to_string(),
to: "shaded.com.google.common".to_string(),
excludes: vec!["com.google.common.annotations.*".to_string()],
}],
section_present: true,
};
desc.dependencies.insert("org.slf4j:slf4j-api".to_string(), shaded_dependency("2.0.13", Some(false), vec![]));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let shade_plugin = xml.split("<plugin>").find(|b| b.contains("maven-shade-plugin")).unwrap();
assert!(shade_plugin.contains(&format!("<version>{}</version>", plugin_versions::SHADE)));
assert!(shade_plugin.contains("<phase>package</phase>"));
assert!(shade_plugin.contains("<goal>shade</goal>"));
assert!(shade_plugin.contains("<shadedClassifierName>fat</shadedClassifierName>"));
assert!(shade_plugin.contains("<shadedArtifactAttached>true</shadedArtifactAttached>"));
assert!(shade_plugin.contains(&format!(r#"<transformer implementation="{SHADE_SERVICES_TRANSFORMER}"/>"#)));
assert!(shade_plugin.contains(&format!(r#"<transformer implementation="{SHADE_MANIFEST_TRANSFORMER}">"#)));
assert!(shade_plugin.contains("<mainClass>com.example.Main</mainClass>"));
assert!(shade_plugin.contains("<artifactSet>"));
assert!(shade_plugin.contains("<excludes>"));
assert!(shade_plugin.contains("<exclude>org.slf4j:slf4j-api</exclude>"));
assert!(shade_plugin.contains("<relocations>"));
assert!(shade_plugin.contains("<pattern>com.google.common</pattern>"));
assert!(shade_plugin.contains("<shadedPattern>shaded.com.google.common</shadedPattern>"));
assert!(shade_plugin.contains("<exclude>com.google.common.annotations.*</exclude>"));
}
#[test]
fn shade_all_false_uses_includes() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.fat_jar = FatJar { enabled: true, shade_all: false, relocations: vec![], section_present: true };
desc.dependencies.insert("com.google.guava:guava".to_string(), shaded_dependency("33.2.1-jre", Some(true), vec![]));
desc.dependencies.insert("org.slf4j:slf4j-api".to_string(), DependencyValue::Version("2.0.13".to_string()));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let shade_plugin = xml.split("<plugin>").find(|b| b.contains("maven-shade-plugin")).unwrap();
assert!(shade_plugin.contains("<artifactSet>"));
assert!(shade_plugin.contains("<includes>"));
assert!(shade_plugin.contains("<include>com.google.guava:guava</include>"));
assert!(!shade_plugin.contains("slf4j"));
assert!(!shade_plugin.contains("<relocations>"));
assert!(shade_plugin.contains(&format!(r#"<transformer implementation="{SHADE_SERVICES_TRANSFORMER}"/>"#)));
assert!(!shade_plugin.contains(SHADE_MANIFEST_TRANSFORMER));
}
#[test]
fn fat_jar_disabled_omits_shade_plugin() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("maven-shade-plugin"));
}
#[test]
fn surefire_plugin_omits_includes_without_spock() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<includes>"));
assert!(!xml.contains("<argLine>"));
}
#[test]
fn surefire_plugin_uses_spock_broadened_globs_when_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.spock.enabled = Some(true);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
for pattern in surefire_includes::DEFAULT {
assert!(xml.contains(&format!("<include>{pattern}</include>")), "missing {pattern}");
}
assert!(xml.contains(&format!("<include>{}</include>", surefire_includes::SPOCK_EXTRA)));
}
#[test]
fn enable_preview_adds_surefire_arg_line() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.java.enable_preview = Some(true);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<argLine>--enable-preview</argLine>"));
}
#[test]
fn java_agent_dependency_adds_argline_and_dependency_properties_plugin() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.test_dependencies.insert(
"org.mockito:mockito-core".to_string(),
DependencyValue::Detailed(DependencyDetailed {
version: String::new(),
repository: None,
java_agent: true,
exclusions: vec![],
shade: None,
relocations: vec![],
allow_version_conflict: false,
}),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>"));
assert!(xml.contains("<artifactId>maven-dependency-plugin</artifactId>"));
assert!(xml.contains("<goal>properties</goal>"));
}
#[test]
fn coverage_enabled_prepends_argline_placeholder_and_adds_jacoco_plugin() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.test.coverage = Some(true);
desc.java.enable_preview = Some(true);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<argLine>@{argLine} --enable-preview</argLine>"));
assert!(xml.contains("<artifactId>jacoco-maven-plugin</artifactId>"));
assert!(xml.contains("<goal>prepare-agent</goal>"));
let report = xml.split("<execution>").find(|b| b.contains("<id>report</id>")).unwrap();
assert!(report.contains("<phase>test</phase>"));
assert!(report.contains("<goal>report</goal>"));
}
#[test]
fn coverage_disabled_has_no_jacoco_plugin() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("jacoco-maven-plugin"));
}
#[test]
fn junit_standalone_added_when_no_engine_declared() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let dep = project
.dependencies
.iter()
.find(|d| d.group_id == JUNIT_STANDALONE_GROUP_ID && d.artifact_id == JUNIT_STANDALONE_ARTIFACT_ID)
.unwrap();
assert_eq!(dep.scope.as_deref(), Some("test"));
assert_eq!(dep.version.as_deref(), Some(desc.test.junit_platform_version()));
}
#[test]
fn junit_standalone_omitted_when_jupiter_declared() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.test_dependencies.insert(
"org.junit.jupiter:junit-jupiter".to_string(),
DependencyValue::Version("5.10.0".to_string()),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert!(!project
.dependencies
.iter()
.any(|d| d.artifact_id == JUNIT_STANDALONE_ARTIFACT_ID));
}
#[test]
fn junit_standalone_omitted_when_spock_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.spock.enabled = Some(true);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert!(!project
.dependencies
.iter()
.any(|d| d.artifact_id == JUNIT_STANDALONE_ARTIFACT_ID));
}
#[test]
fn spock_enabled_adds_spock_core_test_dependency() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
desc.spock.enabled = Some(true);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let dep = project.dependencies.iter().find(|d| d.artifact_id == "spock-core").unwrap();
assert_eq!(dep.group_id, "org.spockframework");
assert_eq!(dep.version.as_deref(), Some(DEFAULT_SPOCK_VERSION));
assert_eq!(dep.scope.as_deref(), Some("test"));
}
fn jackson_databind_dep(desc: &mut Descriptor) {
desc.dependencies.insert(
"com.fasterxml.jackson.core:jackson-databind".to_string(),
DependencyValue::Version("2.17.2".to_string()),
);
}
#[test]
fn jar_plugin_always_disables_maven_descriptor() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_library("my-lib", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<artifactId>maven-jar-plugin</artifactId>"));
assert!(xml.contains("<addMavenDescriptor>false</addMavenDescriptor>"));
}
#[test]
fn no_main_class_omits_manifest_block() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_library("my-lib", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<manifest>"));
}
#[test]
fn application_with_dependencies_gets_manifest_and_copy_dependencies() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
jackson_databind_dep(&mut desc);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<mainClass>com.example.Main</mainClass>"));
assert!(xml.contains("<addClasspath>true</addClasspath>"));
assert!(xml.contains("<classpathPrefix>libs/</classpathPrefix>"));
assert!(xml.contains("<artifactId>maven-dependency-plugin</artifactId>"));
assert!(xml.contains("<id>copy-dependencies</id>"));
assert!(xml.contains("<phase>package</phase>"));
assert!(xml.contains("<goal>copy-dependencies</goal>"));
assert!(xml.contains("<outputDirectory>${project.build.directory}/libs</outputDirectory>"));
assert!(xml.contains("<includeScope>runtime</includeScope>"));
}
#[test]
fn application_without_dependencies_skips_classpath_and_copy_dependencies() {
let dir = tempfile::tempdir().unwrap();
let desc = minimal_app("my-app", Some("com.example"));
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<mainClass>com.example.Main</mainClass>"));
assert!(!xml.contains("<addClasspath>"));
assert!(!xml.contains("<classpathPrefix>"));
assert!(!xml.contains("copy-dependencies"));
}
#[test]
fn fat_jar_enabled_skips_classpath_and_copy_dependencies() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
jackson_databind_dep(&mut desc);
desc.fat_jar.enabled = true;
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<mainClass>com.example.Main</mainClass>"));
assert!(!xml.contains("<addClasspath>"));
assert!(!xml.contains("<classpathPrefix>"));
assert!(!xml.contains("copy-dependencies"));
}
#[test]
fn library_has_no_main_class_or_libs_even_with_dependencies() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_library("my-lib", Some("com.example"));
jackson_databind_dep(&mut desc);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(!xml.contains("<manifest>"));
assert!(!xml.contains("copy-dependencies"));
}
#[test]
fn java_agent_and_copy_dependencies_share_one_dependency_plugin() {
let dir = tempfile::tempdir().unwrap();
let mut desc = minimal_app("my-app", Some("com.example"));
jackson_databind_dep(&mut desc);
desc.test_dependencies.insert(
"org.mockito:mockito-core".to_string(),
DependencyValue::Detailed(DependencyDetailed {
version: String::new(),
repository: None,
java_agent: true,
exclusions: vec![],
shade: None,
relocations: vec![],
allow_version_conflict: false,
}),
);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), Some("com.example.Main"), &BTreeMap::new()).unwrap();
assert_eq!(project.plugins.iter().filter(|p| p.artifact_id == "maven-dependency-plugin").count(), 1);
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<goal>properties</goal>"));
assert!(xml.contains("<goal>copy-dependencies</goal>"));
}
#[test]
fn same_inputs_render_byte_identical_pom() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let p1 = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let p2 = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
assert_eq!(render(&p1, "deadbeef").unwrap(), render(&p2, "deadbeef").unwrap());
}
#[test]
fn fingerprint_changes_with_schema_version() {
let layout = MavenLayout::default();
let fp1 = fingerprint(1, b"toml", None, &layout, &[]);
let fp2 = fingerprint(2, b"toml", None, &layout, &[]);
assert_ne!(fp1, fp2);
}
#[test]
fn pom_not_rewritten_when_fingerprint_unchanged() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let pom_path = dir.path().join("pom.xml");
let outcome = sync_pom(dir.path(), &pom_path, &desc, b"toml-bytes", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
assert_eq!(outcome, SyncOutcome::Written);
let metadata_before = std::fs::metadata(&pom_path).unwrap();
let modified_before = metadata_before.modified().unwrap();
let outcome = sync_pom(dir.path(), &pom_path, &desc, b"toml-bytes", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
assert_eq!(outcome, SyncOutcome::UpToDate);
let modified_after = std::fs::metadata(&pom_path).unwrap().modified().unwrap();
assert_eq!(modified_before, modified_after);
}
#[test]
fn pom_rewritten_when_curie_toml_changes() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let pom_path = dir.path().join("pom.xml");
sync_pom(dir.path(), &pom_path, &desc, b"v1", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
let outcome = sync_pom(dir.path(), &pom_path, &desc, b"v2", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
assert_eq!(outcome, SyncOutcome::Written);
}
#[test]
fn pom_rewritten_when_source_root_added() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let pom_path = dir.path().join("pom.xml");
sync_pom(dir.path(), &pom_path, &desc, b"toml", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
let outcome = sync_pom(dir.path(), &pom_path, &desc, b"toml", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
assert_eq!(outcome, SyncOutcome::UpToDate);
write_file(dir.path(), "src/main/kotlin/com/example/World.kt", "package com.example\nclass World");
let outcome = sync_pom(dir.path(), &pom_path, &desc, b"toml", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap();
assert_eq!(outcome, SyncOutcome::Written);
}
#[test]
fn refuses_to_overwrite_unmarked_pom() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let pom_path = dir.path().join("pom.xml");
std::fs::write(&pom_path, "<project>hand written</project>").unwrap();
let err = sync_pom(dir.path(), &pom_path, &desc, b"toml", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), false, false).unwrap_err().to_string();
assert!(err.contains("--force"), "got: {err}");
assert_eq!(std::fs::read_to_string(&pom_path).unwrap(), "<project>hand written</project>");
}
#[test]
fn force_overwrites_unmarked_pom() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let desc = minimal_app("my-app", Some("com.example"));
let pom_path = dir.path().join("pom.xml");
std::fs::write(&pom_path, "<project>hand written</project>").unwrap();
let outcome = sync_pom(dir.path(), &pom_path, &desc, b"toml", None, &[], &BTreeMap::new(), None, &BTreeMap::new(), true, false).unwrap();
assert_eq!(outcome, SyncOutcome::Written);
assert!(std::fs::read_to_string(&pom_path).unwrap().contains("curie-maven-fingerprint"));
}
#[test]
fn check_mode_exits_nonzero_when_stale_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\ngroupId = \"com.example\"\nmainClass = \"com.example.Main\"\n",
)
.unwrap();
let pom_path = dir.path().join("pom.xml");
assert!(run_maven_sync_standalone(dir.path(), false, true, true).unwrap());
assert!(!pom_path.exists());
assert!(run_maven_sync_standalone(dir.path(), false, false, true).unwrap());
assert!(pom_path.exists());
assert!(!run_maven_sync_standalone(dir.path(), false, true, true).unwrap());
}
#[test]
fn main_class_declared_wins_over_detection() {
let dir = tempfile::tempdir().unwrap();
write_file(
dir.path(),
"src/main/java/com/example/Other.java",
"package com.example;\npublic class Other { public static void main(String[] args) {} }",
);
let desc = minimal_app("my-app", Some("com.example"));
let layout = discover_layout(dir.path());
let main_class = resolve_main_class_for_sync(&desc, dir.path(), &layout).unwrap();
assert_eq!(main_class, Some("Main".to_string()));
}
#[test]
fn main_class_detection_fails_with_helpful_error_when_no_candidates() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
let mut desc = minimal_app("my-app", Some("com.example"));
match &mut desc.kind {
DescriptorKind::Application(app) => app.main_class = None,
_ => unreachable!(),
}
let layout = discover_layout(dir.path());
let err = resolve_main_class_for_sync(&desc, dir.path(), &layout).unwrap_err();
let chain = format!("{err:#}");
assert!(chain.contains("no main method found"), "got: {chain}");
assert!(chain.contains("mainClass"), "got: {chain}");
}
fn write_two_member_workspace(dir: &Path) -> (PathBuf, PathBuf) {
std::fs::write(dir.join("Curie.toml"), "[workspace]\nmembers = [\"lib\", \"app\"]\n").unwrap();
let lib_dir = dir.join("lib");
std::fs::create_dir_all(&lib_dir).unwrap();
std::fs::write(
lib_dir.join("Curie.toml"),
"[library]\nname = \"lib\"\nversion = \"0.1.0\"\ngroupId = \"com.example\"\n",
)
.unwrap();
let app_dir = dir.join("app");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("Curie.toml"),
"[application]\nname = \"app\"\nversion = \"0.1.0\"\ngroupId = \"com.example\"\nmainClass = \"com.example.App\"\n\
[workspace-dependencies]\nlib = { path = \"../lib\" }\n",
)
.unwrap();
(lib_dir, app_dir)
}
#[test]
fn run_maven_sync_workspace_root_writes_aggregator_and_member_poms() {
let dir = tempfile::tempdir().unwrap();
let (lib_dir, app_dir) = write_two_member_workspace(dir.path());
assert!(run_maven_sync_workspace_root(dir.path(), false, false, true).unwrap());
let root_pom = std::fs::read_to_string(dir.path().join("pom.xml")).unwrap();
assert!(root_pom.contains("<module>lib</module>"));
assert!(root_pom.contains("<module>app</module>"));
let app_pom = std::fs::read_to_string(app_dir.join("pom.xml")).unwrap();
assert!(app_pom.contains("<artifactId>lib</artifactId>"));
assert!(app_pom.contains("<groupId>com.example</groupId>"));
assert!(lib_dir.join("pom.xml").exists());
assert!(!run_maven_sync_workspace_root(dir.path(), false, false, true).unwrap());
}
#[test]
fn run_maven_sync_workspace_member_syncs_only_that_member() {
let dir = tempfile::tempdir().unwrap();
let (lib_dir, app_dir) = write_two_member_workspace(dir.path());
let ws = workspace::load(dir.path()).unwrap();
let app_index = ws.members.iter().position(|m| m.declared == "app").unwrap();
assert!(run_maven_sync_workspace_member(dir.path(), app_index, false, false, true).unwrap());
assert!(app_dir.join("pom.xml").exists());
assert!(!dir.path().join("pom.xml").exists(), "aggregator POM is only synced at the workspace root");
assert!(!lib_dir.join("pom.xml").exists(), "only the targeted member is synced");
}
#[test]
fn sync_for_build_noop_when_sync_disabled() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\ngroupId = \"com.example\"\nmainClass = \"com.example.Main\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
sync_for_build(dir.path(), &desc, true).unwrap();
assert!(!dir.path().join("pom.xml").exists());
}
#[test]
fn sync_for_build_writes_pom_when_sync_enabled() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\ngroupId = \"com.example\"\nmainClass = \"com.example.Main\"\n\
[maven]\nsync = true\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
sync_for_build(dir.path(), &desc, true).unwrap();
let pom = std::fs::read_to_string(dir.path().join("pom.xml")).unwrap();
assert!(pom.contains("curie-maven-fingerprint"));
}
#[test]
fn sync_for_build_fails_on_unmarked_pom() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\ngroupId = \"com.example\"\nmainClass = \"com.example.Main\"\n\
[maven]\nsync = true\n",
)
.unwrap();
let pom_path = dir.path().join("pom.xml");
std::fs::write(&pom_path, "<project>hand written</project>").unwrap();
let desc = descriptor::load(dir.path()).unwrap();
let err = sync_for_build(dir.path(), &desc, true).unwrap_err().to_string();
assert!(err.contains("--force"), "got: {err}");
assert_eq!(std::fs::read_to_string(&pom_path).unwrap(), "<project>hand written</project>");
}
fn write_two_member_workspace_with_member_sync(dir: &Path) -> (PathBuf, PathBuf) {
let (lib_dir, app_dir) = write_two_member_workspace(dir);
let mut app_toml = std::fs::read_to_string(app_dir.join("Curie.toml")).unwrap();
app_toml.push_str("\n[maven]\nsync = true\n");
std::fs::write(app_dir.join("Curie.toml"), app_toml).unwrap();
(lib_dir, app_dir)
}
#[test]
fn sync_member_for_build_respects_per_member_sync_flag() {
let dir = tempfile::tempdir().unwrap();
let (lib_dir, app_dir) = write_two_member_workspace_with_member_sync(dir.path());
let ws = workspace::load(dir.path()).unwrap();
for index in 0..ws.members.len() {
sync_member_for_build(&ws, index, true).unwrap();
}
assert!(app_dir.join("pom.xml").exists(), "app has [maven] sync = true");
assert!(!lib_dir.join("pom.xml").exists(), "lib has no [maven] section, sync stays disabled");
}
#[test]
fn sync_aggregator_for_build_gated_on_workspace_roots_own_sync_flag() {
let dir = tempfile::tempdir().unwrap();
write_two_member_workspace_with_member_sync(dir.path());
sync_aggregator_for_build(dir.path()).unwrap();
assert!(!dir.path().join("pom.xml").exists());
let mut root_toml = std::fs::read_to_string(dir.path().join("Curie.toml")).unwrap();
root_toml.push_str("\n[maven]\nsync = true\n");
std::fs::write(dir.path().join("Curie.toml"), root_toml).unwrap();
sync_aggregator_for_build(dir.path()).unwrap();
let root_pom = std::fs::read_to_string(dir.path().join("pom.xml")).unwrap();
assert!(root_pom.contains("<module>lib</module>"));
assert!(root_pom.contains("<module>app</module>"));
}
#[test]
fn unrepresented_plugin_names_empty_without_plugin_sections() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\nmainClass = \"com.example.Main\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert!(unrepresented_plugin_names(&desc).is_empty());
}
#[test]
fn unrepresented_plugin_names_excludes_represented_plugins() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\nmainClass = \"com.example.Main\"\n\
[plugin.protobuf]\nversion = \"3.25.0\"\n\
[plugin.openapi]\nversion = \"7.2.0\"\nspecFile = \"api/spec.yaml\"\ngeneratorName = \"java\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert!(unrepresented_plugin_names(&desc).is_empty());
}
#[test]
fn unrepresented_plugin_names_lists_unknown_plugin() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\nmainClass = \"com.example.Main\"\n\
[plugin.custom-codegen]\nversion = \"1.0\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert_eq!(unrepresented_plugin_names(&desc), vec!["custom-codegen"]);
}
#[test]
fn sync_member_pom_emits_plugin_warning_for_unknown_plugin() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "src/main/java/com/example/Hello.java", "package com.example; class Hello {}");
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"my-app\"\nversion = \"1.0.0\"\ngroupId = \"com.example\"\nmainClass = \"com.example.Main\"\n\
[plugin.custom-codegen]\nversion = \"1.0\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
let (pom_path, outcome) = sync_member_pom(dir.path(), &desc, None, &BTreeMap::new(), false, false, true).unwrap();
assert_eq!(outcome, SyncOutcome::Written);
let pom = std::fs::read_to_string(pom_path).unwrap();
assert!(pom.contains("curie-maven-fingerprint"));
}
fn protobuf_only_desc(dir: &Path, extra_toml: &str) -> Descriptor {
std::fs::write(
dir.join("Curie.toml"),
format!(
"[application]\nname = \"proto-app\"\nversion = \"0.1.0\"\nmainClass = \"com.example.Main\"\n\
[plugin.protobuf]\nversion = \"3.25.0\"\n{extra_toml}"
),
)
.unwrap();
descriptor::load(dir).unwrap()
}
#[test]
fn protobuf_plugin_emitted_in_pom() {
let dir = tempfile::tempdir().unwrap();
let desc = protobuf_only_desc(dir.path(), "");
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let proto_block = xml.split("<plugin>").find(|b| b.contains("protobuf-maven-plugin")).expect("protobuf-maven-plugin not found");
assert!(proto_block.contains("<groupId>io.github.ascopes</groupId>"));
assert!(proto_block.contains(&format!("<version>{}</version>", plugin_versions::PROTOBUF_MAVEN)));
assert!(proto_block.contains("<protoc>3.25.0</protoc>"));
assert!(proto_block.contains("<sourceDirectory>proto</sourceDirectory>"));
assert!(proto_block.contains("<goal>generate</goal>"));
assert!(!proto_block.contains("grpc"), "no gRPC when grpc=false");
}
#[test]
fn protobuf_plugin_custom_source_dir() {
let dir = tempfile::tempdir().unwrap();
let desc = protobuf_only_desc(dir.path(), r#"sourceDir = "src/main/proto""#);
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
assert!(xml.contains("<sourceDirectory>src/main/proto</sourceDirectory>"));
}
#[test]
fn protobuf_grpc_adds_binary_plugin() {
let dir = tempfile::tempdir().unwrap();
let desc = protobuf_only_desc(dir.path(), "grpc = true\ngrpcVersion = \"1.60.0\"\n");
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let proto_block = xml.split("<plugin>").find(|b| b.contains("protobuf-maven-plugin")).unwrap();
assert!(proto_block.contains("binaryMavenPlugin"));
assert!(proto_block.contains("<artifactId>protoc-gen-grpc-java</artifactId>"));
assert!(proto_block.contains("<version>1.60.0</version>"));
}
#[test]
fn openapi_plugin_emitted_in_pom() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"openapi-app\"\nversion = \"0.1.0\"\nmainClass = \"com.example.Main\"\n\
[plugin.openapi]\nversion = \"7.2.0\"\nspecFile = \"api/greeter.yaml\"\ngeneratorName = \"java\"\n\
modelPackage = \"com.example.model\"\n\
[plugin.openapi.additionalProperties]\nlibrary = \"native\"\nhideGenerationTimestamp = \"true\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let oa_block = xml.split("<plugin>").find(|b| b.contains("openapi-generator-maven-plugin")).expect("openapi-generator-maven-plugin not found");
assert!(oa_block.contains("<groupId>org.openapitools</groupId>"));
assert!(oa_block.contains("<version>7.2.0</version>"));
assert!(oa_block.contains("<goal>generate</goal>"));
assert!(oa_block.contains("api/greeter.yaml"));
assert!(oa_block.contains("<generatorName>java</generatorName>"));
assert!(oa_block.contains("<modelPackage>com.example.model</modelPackage>"));
assert!(oa_block.contains("<configOptions>"));
assert!(oa_block.contains("<library>native</library>"));
assert!(oa_block.contains("<hideGenerationTimestamp>true</hideGenerationTimestamp>"));
}
#[test]
fn openapi_global_properties_mapped() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"openapi-app\"\nversion = \"0.1.0\"\nmainClass = \"com.example.Main\"\n\
[plugin.openapi]\nversion = \"7.2.0\"\nspecFile = \"api/spec.yaml\"\ngeneratorName = \"java\"\n\
[plugin.openapi.globalProperties]\nmodels = \"\"\napis = \"\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
let project = build_project(&desc, dir.path(), &[], &BTreeMap::new(), None, &BTreeMap::new()).unwrap();
let xml = render(&project, "deadbeef").unwrap();
let oa_block = xml.split("<plugin>").find(|b| b.contains("openapi-generator-maven-plugin")).unwrap();
assert!(oa_block.contains("<globalProperties>"));
assert!(oa_block.contains("<apis></apis>"));
assert!(oa_block.contains("<models></models>"));
}
}