diff --git a/pom.xml b/pom.xml index 327a618b1..48ac7b902 100644 --- a/pom.xml +++ b/pom.xml @@ -143,6 +143,18 @@ under the License. provided + + + eu.maveniverse.maven.domtrip + domtrip-maven + 1.2.0 + + + eu.maveniverse.maven.domtrip + domtrip-core + 1.2.0 + + org.apache.maven.doxia diff --git a/src/it/projects/add-dependency/basic/invoker.properties b/src/it/projects/add-dependency/basic/invoker.properties new file mode 100644 index 000000000..e2d7d3921 --- /dev/null +++ b/src/it/projects/add-dependency/basic/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:add -Dgav=org.apache.commons:commons-lang3:3.17.0 diff --git a/src/it/projects/add-dependency/basic/pom.xml b/src/it/projects/add-dependency/basic/pom.xml new file mode 100644 index 000000000..8810439be --- /dev/null +++ b/src/it/projects/add-dependency/basic/pom.xml @@ -0,0 +1,29 @@ + + + + + + 4.0.0 + test + add-dependency-basic + 1.0-SNAPSHOT + diff --git a/src/it/projects/add-dependency/basic/verify.groovy b/src/it/projects/add-dependency/basic/verify.groovy new file mode 100644 index 000000000..59bad6a1c --- /dev/null +++ b/src/it/projects/add-dependency/basic/verify.groovy @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +File pom = new File(basedir, "pom.xml") +assert pom.exists() +def xml = new groovy.xml.XmlSlurper().parseText(pom.text) +def deps = xml.dependencies.dependency +def dep = deps.find { it.artifactId.text() == 'commons-lang3' } +assert dep != null : "commons-lang3 should be in dependencies" +assert dep.groupId.text() == 'org.apache.commons' +assert dep.version.text() == '3.17.0' diff --git a/src/it/projects/add-dependency/gav-shorthand/invoker.properties b/src/it/projects/add-dependency/gav-shorthand/invoker.properties new file mode 100644 index 000000000..22d0d90e1 --- /dev/null +++ b/src/it/projects/add-dependency/gav-shorthand/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dscope=test diff --git a/src/it/projects/add-dependency/gav-shorthand/pom.xml b/src/it/projects/add-dependency/gav-shorthand/pom.xml new file mode 100644 index 000000000..3066574cc --- /dev/null +++ b/src/it/projects/add-dependency/gav-shorthand/pom.xml @@ -0,0 +1,29 @@ + + + + + + 4.0.0 + test + add-dependency-gav-shorthand + 1.0-SNAPSHOT + diff --git a/src/it/projects/add-dependency/gav-shorthand/verify.groovy b/src/it/projects/add-dependency/gav-shorthand/verify.groovy new file mode 100644 index 000000000..4175b9af8 --- /dev/null +++ b/src/it/projects/add-dependency/gav-shorthand/verify.groovy @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +File pom = new File(basedir, "pom.xml") +assert pom.exists() +def xml = new groovy.xml.XmlSlurper().parseText(pom.text) +def deps = xml.dependencies.dependency +def dep = deps.find { it.artifactId.text() == 'commons-lang3' } +assert dep != null : "commons-lang3 should be in dependencies" +assert dep.groupId.text() == 'org.apache.commons' +assert dep.version.text() == '3.17.0' +assert dep.scope.text() == 'test' diff --git a/src/it/projects/add-dependency/managed/invoker.properties b/src/it/projects/add-dependency/managed/invoker.properties new file mode 100644 index 000000000..d4e4935fe --- /dev/null +++ b/src/it/projects/add-dependency/managed/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dmanaged diff --git a/src/it/projects/add-dependency/managed/pom.xml b/src/it/projects/add-dependency/managed/pom.xml new file mode 100644 index 000000000..da6d13b58 --- /dev/null +++ b/src/it/projects/add-dependency/managed/pom.xml @@ -0,0 +1,29 @@ + + + + + + 4.0.0 + test + add-dependency-managed + 1.0-SNAPSHOT + diff --git a/src/it/projects/add-dependency/managed/verify.groovy b/src/it/projects/add-dependency/managed/verify.groovy new file mode 100644 index 000000000..2506ab6f0 --- /dev/null +++ b/src/it/projects/add-dependency/managed/verify.groovy @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +File pom = new File(basedir, "pom.xml") +assert pom.exists() +def xml = new groovy.xml.XmlSlurper().parseText(pom.text) +def managedDeps = xml.dependencyManagement.dependencies.dependency +def dep = managedDeps.find { it.artifactId.text() == 'commons-lang3' } +assert dep != null : "commons-lang3 should be in dependencyManagement" +assert dep.groupId.text() == 'org.apache.commons' +assert dep.version.text() == '3.17.0' diff --git a/src/it/projects/add-dependency/multimodule-dash/invoker.properties b/src/it/projects/add-dependency/multimodule-dash/invoker.properties new file mode 100644 index 000000000..119e5ae26 --- /dev/null +++ b/src/it/projects/add-dependency/multimodule-dash/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:add -Dgav=com.google.guava:guava:33.0.0-jre diff --git a/src/it/projects/add-dependency/multimodule-dash/parent/pom.xml b/src/it/projects/add-dependency/multimodule-dash/parent/pom.xml new file mode 100644 index 000000000..825f98854 --- /dev/null +++ b/src/it/projects/add-dependency/multimodule-dash/parent/pom.xml @@ -0,0 +1,50 @@ + + + + + + 4.0.0 + test + multimodule-dash-parent + 1.0-SNAPSHOT + pom + + + 5.10.2 + 2.0.12 + + + + + + org.junit.jupiter + junit-jupiter + ${junit-version} + + + org.slf4j + slf4j-api + ${slf4j-version} + + + + diff --git a/src/it/projects/add-dependency/multimodule-dash/pom.xml b/src/it/projects/add-dependency/multimodule-dash/pom.xml new file mode 100644 index 000000000..5c8052fc9 --- /dev/null +++ b/src/it/projects/add-dependency/multimodule-dash/pom.xml @@ -0,0 +1,46 @@ + + + + + + 4.0.0 + + + test + multimodule-dash-parent + 1.0-SNAPSHOT + parent/pom.xml + + + multimodule-dash-child + + + + org.junit.jupiter + junit-jupiter + + + org.slf4j + slf4j-api + + + diff --git a/src/it/projects/add-dependency/multimodule-dash/verify.groovy b/src/it/projects/add-dependency/multimodule-dash/verify.groovy new file mode 100644 index 000000000..5aabd759f --- /dev/null +++ b/src/it/projects/add-dependency/multimodule-dash/verify.groovy @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Verify parent POM got the property with -version suffix and managed dependency +File parentPom = new File(basedir, "parent/pom.xml") +assert parentPom.exists() +def parentContent = parentPom.text + +// Parent should have the guava-version property (dash convention) +assert parentContent.contains('33.0.0-jre') + +// Parent should have the managed dependency with property reference +def parentXml = new groovy.xml.XmlSlurper().parseText(parentContent) +def managedDeps = parentXml.dependencyManagement.dependencies.dependency +def guavaManaged = managedDeps.find { it.artifactId.text() == 'guava' } +assert guavaManaged != null : "guava should be in parent's dependencyManagement" +assert guavaManaged.groupId.text() == 'com.google.guava' +assert guavaManaged.version.text() == '${guava-version}' + +// Verify child POM got a version-less dependency +File childPom = new File(basedir, "pom.xml") +assert childPom.exists() +def childContent = childPom.text +def childXml = new groovy.xml.XmlSlurper().parseText(childContent) +def childDeps = childXml.dependencies.dependency +def guavaChild = childDeps.find { it.artifactId.text() == 'guava' } +assert guavaChild != null : "guava should be in child's dependencies" +assert guavaChild.groupId.text() == 'com.google.guava' +// Child should NOT have a version element +assert guavaChild.version.text() == '' : "child dependency should be version-less" diff --git a/src/it/projects/add-dependency/multimodule/invoker.properties b/src/it/projects/add-dependency/multimodule/invoker.properties new file mode 100644 index 000000000..119e5ae26 --- /dev/null +++ b/src/it/projects/add-dependency/multimodule/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:add -Dgav=com.google.guava:guava:33.0.0-jre diff --git a/src/it/projects/add-dependency/multimodule/parent/pom.xml b/src/it/projects/add-dependency/multimodule/parent/pom.xml new file mode 100644 index 000000000..b36f68528 --- /dev/null +++ b/src/it/projects/add-dependency/multimodule/parent/pom.xml @@ -0,0 +1,50 @@ + + + + + + 4.0.0 + test + multimodule-parent + 1.0-SNAPSHOT + pom + + + 5.10.2 + 2.0.12 + + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + diff --git a/src/it/projects/add-dependency/multimodule/pom.xml b/src/it/projects/add-dependency/multimodule/pom.xml new file mode 100644 index 000000000..ba7bdf597 --- /dev/null +++ b/src/it/projects/add-dependency/multimodule/pom.xml @@ -0,0 +1,46 @@ + + + + + + 4.0.0 + + + test + multimodule-parent + 1.0-SNAPSHOT + parent/pom.xml + + + multimodule-child + + + + org.junit.jupiter + junit-jupiter + + + org.slf4j + slf4j-api + + + diff --git a/src/it/projects/add-dependency/multimodule/verify.groovy b/src/it/projects/add-dependency/multimodule/verify.groovy new file mode 100644 index 000000000..f53f06eaf --- /dev/null +++ b/src/it/projects/add-dependency/multimodule/verify.groovy @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Verify parent POM got the property and managed dependency +File parentPom = new File(basedir, "parent/pom.xml") +assert parentPom.exists() +def parentContent = parentPom.text + +// Parent should have the guava.version property +assert parentContent.contains('33.0.0-jre') + +// Parent should have the managed dependency with property reference +def parentXml = new groovy.xml.XmlSlurper().parseText(parentContent) +def managedDeps = parentXml.dependencyManagement.dependencies.dependency +def guavaManaged = managedDeps.find { it.artifactId.text() == 'guava' } +assert guavaManaged != null : "guava should be in parent's dependencyManagement" +assert guavaManaged.groupId.text() == 'com.google.guava' +assert guavaManaged.version.text() == '${guava.version}' + +// Verify child POM got a version-less dependency +File childPom = new File(basedir, "pom.xml") +assert childPom.exists() +def childContent = childPom.text +def childXml = new groovy.xml.XmlSlurper().parseText(childContent) +def childDeps = childXml.dependencies.dependency +def guavaChild = childDeps.find { it.artifactId.text() == 'guava' } +assert guavaChild != null : "guava should be in child's dependencies" +assert guavaChild.groupId.text() == 'com.google.guava' +// Child should NOT have a version element +assert guavaChild.version.text() == '' : "child dependency should be version-less" diff --git a/src/it/projects/remove-dependency/basic/invoker.properties b/src/it/projects/remove-dependency/basic/invoker.properties new file mode 100644 index 000000000..1ab3d2ee1 --- /dev/null +++ b/src/it/projects/remove-dependency/basic/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:remove -Dgav=org.apache.commons:commons-lang3 diff --git a/src/it/projects/remove-dependency/basic/pom.xml b/src/it/projects/remove-dependency/basic/pom.xml new file mode 100644 index 000000000..c6d1d5cdb --- /dev/null +++ b/src/it/projects/remove-dependency/basic/pom.xml @@ -0,0 +1,36 @@ + + + + + + 4.0.0 + test + remove-dependency-basic + 1.0-SNAPSHOT + + + org.apache.commons + commons-lang3 + 3.17.0 + + + diff --git a/src/it/projects/remove-dependency/basic/verify.groovy b/src/it/projects/remove-dependency/basic/verify.groovy new file mode 100644 index 000000000..385591aa4 --- /dev/null +++ b/src/it/projects/remove-dependency/basic/verify.groovy @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +File pom = new File(basedir, "pom.xml") +assert pom.exists() +def xml = new groovy.xml.XmlSlurper().parseText(pom.text) +def deps = xml.dependencies.dependency +def dep = deps.find { it.artifactId.text() == 'commons-lang3' } +assert dep == null : "commons-lang3 should have been removed from dependencies" diff --git a/src/it/projects/remove-dependency/not-found/invoker.properties b/src/it/projects/remove-dependency/not-found/invoker.properties new file mode 100644 index 000000000..ac8d045fb --- /dev/null +++ b/src/it/projects/remove-dependency/not-found/invoker.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.buildResult = failure +invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:remove -Dgav=nonexistent:lib diff --git a/src/it/projects/remove-dependency/not-found/pom.xml b/src/it/projects/remove-dependency/not-found/pom.xml new file mode 100644 index 000000000..ae6727946 --- /dev/null +++ b/src/it/projects/remove-dependency/not-found/pom.xml @@ -0,0 +1,29 @@ + + + + + + 4.0.0 + test + remove-dependency-not-found + 1.0-SNAPSHOT + diff --git a/src/it/projects/remove-dependency/not-found/verify.groovy b/src/it/projects/remove-dependency/not-found/verify.groovy new file mode 100644 index 000000000..320bd9b08 --- /dev/null +++ b/src/it/projects/remove-dependency/not-found/verify.groovy @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +def log = new File(basedir, "build.log") +assert log.text.contains('not found') diff --git a/src/main/java/org/apache/maven/plugins/dependency/AbstractDependencyMojo.java b/src/main/java/org/apache/maven/plugins/dependency/AbstractDependencyMojo.java index 933804b4c..4b233916a 100644 --- a/src/main/java/org/apache/maven/plugins/dependency/AbstractDependencyMojo.java +++ b/src/main/java/org/apache/maven/plugins/dependency/AbstractDependencyMojo.java @@ -18,12 +18,17 @@ */ package org.apache.maven.plugins.dependency; +import java.util.List; + import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.logging.SystemStreamLog; import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.dependency.pom.DependencyEntry; import org.apache.maven.plugins.dependency.utils.DependencySilentLog; import org.apache.maven.project.MavenProject; import org.sonatype.plexus.build.incremental.BuildContext; @@ -148,4 +153,38 @@ public void setSilent(boolean silent) { setLog(new SystemStreamLog()); } } + + /** + * Checks whether the dependency exists in the project's declared (original) model + * after property interpolation, but before inheritance merging. + * This catches dependencies declared with property references like {@code ${project.groupId}} + * without false-positiving on inherited dependencies from a parent POM. + */ + protected static boolean existsInResolvedModel(MavenProject project, DependencyEntry coords, boolean managed) { + List deps; + org.apache.maven.model.Model originalModel = project.getOriginalModel(); + if (managed) { + DependencyManagement depMgmt = originalModel != null ? originalModel.getDependencyManagement() : null; + deps = depMgmt != null ? depMgmt.getDependencies() : null; + } else { + deps = originalModel != null ? originalModel.getDependencies() : null; + } + if (deps == null) { + return false; + } + String searchType = (coords.getType() != null && !coords.getType().isEmpty()) ? coords.getType() : "jar"; + String searchClassifier = + (coords.getClassifier() != null && !coords.getClassifier().isEmpty()) ? coords.getClassifier() : ""; + for (Dependency dep : deps) { + if (coords.getGroupId().equals(dep.getGroupId()) + && coords.getArtifactId().equals(dep.getArtifactId())) { + String depType = dep.getType() != null ? dep.getType() : "jar"; + String depClassifier = dep.getClassifier() != null ? dep.getClassifier() : ""; + if (searchType.equals(depType) && searchClassifier.equals(depClassifier)) { + return true; + } + } + } + return false; + } } diff --git a/src/main/java/org/apache/maven/plugins/dependency/AddDependencyMojo.java b/src/main/java/org/apache/maven/plugins/dependency/AddDependencyMojo.java new file mode 100644 index 000000000..1b18a729e --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/dependency/AddDependencyMojo.java @@ -0,0 +1,803 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency; + +import javax.inject.Inject; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import eu.maveniverse.domtrip.Document; +import eu.maveniverse.domtrip.Element; +import eu.maveniverse.domtrip.maven.Coordinates; +import eu.maveniverse.domtrip.maven.PomEditor; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.model.Model; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.dependency.pom.DependencyEntry; +import org.apache.maven.project.MavenProject; +import org.sonatype.plexus.build.incremental.BuildContext; + +/** + * Adds a dependency to the project's {@code pom.xml}. + * Supports adding to {@code } or {@code }, + * with version inference from managed dependencies. + * + *

If the dependency already exists, the goal fails with a descriptive error + * directing the user to remove it first.

+ * + *

The goal uses formatting-preserving DOM manipulation to maintain the POM's + * existing structure (comments, indentation, encoding). Duplicate detection uses + * type and classifier-aware matching, and cross-references Maven's resolved model + * to catch dependencies declared via property references.

+ * + *

Scope values are validated against Maven's known scopes: + * {@code compile}, {@code provided}, {@code runtime}, {@code test}, {@code system}, {@code import}.

+ * + * @since 3.11.0 + */ +@Mojo(name = "add", requiresProject = true, threadSafe = true) +public class AddDependencyMojo extends AbstractDependencyMojo { + + /** + * Dependency coordinates: {@code groupId:artifactId[:version]} + * or {@code groupId:artifactId[:extension[:classifier]]:version}. + * Scope, type, classifier, and optional can be overridden via separate parameters. + */ + @Parameter(property = "gav") + private String gav; + + /** + * Dependency scope. Validated against Maven's known scope values: + * {@code compile}, {@code provided}, {@code runtime}, {@code test}, {@code system}, {@code import}. + * Invalid values are rejected with a {@link org.apache.maven.plugin.MojoFailureException}. + */ + @Parameter(property = "scope") + private String scope; + + /** + * Dependency type/packaging (e.g., {@code jar}, {@code pom}, {@code war}). + */ + @Parameter(property = "type") + private String type; + + /** + * Dependency classifier (e.g., {@code sources}, {@code javadoc}, {@code tests}). + */ + @Parameter(property = "classifier") + private String classifier; + + /** + * Whether the dependency is optional. + */ + @Parameter(property = "optional") + private Boolean optional; + + /** + * When {@code true}, insert into {@code } instead of {@code }. + */ + @Parameter(property = "managed", defaultValue = "false") + private boolean managed; + + /** + * When {@code true} (the default), automatically detect and follow the project's existing + * dependency management conventions: + *
    + *
  • If most existing dependencies are version-less, add managed dependency to parent POM
  • + *
  • If versions use {@code ${...}} property references, create a version property
  • + *
  • Property naming follows the detected pattern (e.g., {@code .version}, {@code -version})
  • + *
+ * Explicit parameters ({@code managed}, {@code useProperty}, {@code propertyName}) override + * detected conventions. + * + * @since 3.11.0 + */ + @Parameter(property = "align", defaultValue = "true") + private boolean align; + + /** + * When set, controls whether a version property is created in {@code }. + * When {@code null} (the default), the behavior is auto-detected from existing conventions + * if {@code align=true}. + * + * @since 3.11.0 + */ + @Parameter(property = "useProperty") + private Boolean useProperty; + + /** + * Explicit property name for the version (e.g., {@code guava.version}). + * When not set, the name is derived from the detected naming convention. + * + * @since 3.11.0 + */ + @Parameter(property = "propertyName") + private String propertyName; + + /** + * Target a specific Maven profile by its {@code }. When set, the dependency is added + * to the profile's {@code } or {@code } section. + * The profile must already exist in the POM. + */ + @Parameter(property = "profile") + private String profile; + + @Inject + public AddDependencyMojo(MavenSession session, BuildContext buildContext, MavenProject project) { + super(session, buildContext, project); + } + + @Override + protected void doExecute() throws MojoExecutionException, MojoFailureException { + DependencyEntry coords = resolveCoordinates(); + + MavenProject targetProject = getProject(); + boolean effectiveManaged = managed; + + // Convention auto-detection when align=true + Conventions conventions = null; + if (align && coords.getVersion() != null) { + conventions = detectConventions(targetProject); + if (!managed) { + effectiveManaged = conventions.useManaged; + } + } + + // Validate version requirements + if (effectiveManaged && coords.getVersion() == null) { + throw new MojoFailureException("Version is required when adding to ." + + " Include version in -Dgav (e.g. groupId:artifactId:version)"); + } + + // Version inference for non-managed dependencies + if (!effectiveManaged && coords.getVersion() == null) { + String managedVersion = findManagedVersion(targetProject, coords.getGroupId(), coords.getArtifactId()); + if (managedVersion != null) { + getLog().info("Version managed by parent: " + coords.getGroupId() + ":" + coords.getArtifactId() + ":" + + managedVersion); + } else { + throw new MojoFailureException("No version specified and no managed version found for " + + coords.getGroupId() + ":" + coords.getArtifactId() + + ". Include version in -Dgav (e.g. groupId:artifactId:version)"); + } + } + + // Determine effective property usage + boolean effectiveUseProperty = false; + String effectivePropertyName = null; + if (conventions != null && coords.getVersion() != null) { + effectiveUseProperty = useProperty != null ? useProperty : conventions.useProperty; + if (effectiveUseProperty) { + effectivePropertyName = propertyName != null ? propertyName : conventions.derivePropertyName(coords); + } + } + + // Determine the target POM for managed deps + File managedPomFile = null; + if (effectiveManaged && conventions != null && conventions.managedPomFile != null) { + managedPomFile = conventions.managedPomFile; + } + + // Cross-POM mode: add managed dep + property to parent, version-less dep to child + File pomFile = targetProject.getFile(); + if (pomFile == null) { + throw new MojoExecutionException("Cannot add dependency: project has no POM file to modify."); + } + + boolean crossPom = effectiveManaged + && managedPomFile != null + && !managedPomFile.getAbsolutePath().equals(pomFile.getAbsolutePath()); + + try { + if (crossPom) { + addCrossPom(coords, pomFile, managedPomFile, effectiveUseProperty, effectivePropertyName); + } else { + addSinglePom( + coords, targetProject, pomFile, effectiveManaged, effectiveUseProperty, effectivePropertyName); + } + } catch (IOException e) { + throw new MojoExecutionException("Failed to modify POM file: " + pomFile, e); + } + } + + /** + * Cross-POM mode: adds a managed dependency (with optional property) to the parent POM, + * and a version-less dependency reference to the child POM. + */ + private void addCrossPom( + DependencyEntry coords, + File childPomFile, + File parentPomFile, + boolean effectiveUseProperty, + String effectivePropertyName) + throws IOException, MojoFailureException { + // 1. Modify parent POM: add property + managed dependency + PomEditor parentEditor = loadPomEditor(parentPomFile); + Element existingManaged = findDependency( + parentEditor, + null, + coords.getGroupId(), + coords.getArtifactId(), + coords.getType(), + coords.getClassifier(), + true); + if (existingManaged != null) { + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + + " already exists in " + parentPomFile.getName() + " ." + + " Remove it first with dependency:remove, then re-add."); + } + + String versionRef = coords.getVersion(); + if (effectiveUseProperty && effectivePropertyName != null) { + parentEditor.properties().updateProperty(true, effectivePropertyName, coords.getVersion()); + versionRef = "${" + effectivePropertyName + "}"; + getLog().info("Added property " + effectivePropertyName + "=" + coords.getVersion() + " to " + + parentPomFile.getName()); + } + + DependencyEntry managedCoords = new DependencyEntry(coords.getGroupId(), coords.getArtifactId()); + managedCoords.setVersion(versionRef); + if (coords.getType() != null && !coords.getType().isEmpty()) { + managedCoords.setType(coords.getType()); + } + if (coords.getClassifier() != null && !coords.getClassifier().isEmpty()) { + managedCoords.setClassifier(coords.getClassifier()); + } + addDependency(parentEditor, parentPomFile, null, managedCoords, true); + savePomEditor(parentEditor, parentPomFile); + getLog().info("Added managed dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + ":" + + versionRef + " to " + parentPomFile.getName()); + + // 2. Modify child POM: add version-less dependency + PomEditor childEditor = loadPomEditor(childPomFile); + Element existingChild = findDependency( + childEditor, + profile, + coords.getGroupId(), + coords.getArtifactId(), + coords.getType(), + coords.getClassifier(), + false); + if (existingChild != null) { + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + + " already exists in " + childPomFile.getName() + + ". Remove it first with dependency:remove, then re-add."); + } else if (existsInResolvedModel(getProject(), coords, false)) { + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + + " already exists in the POM (using property references). " + + "Cannot safely add or update automatically. Please edit the POM manually."); + } + + DependencyEntry childCoords = new DependencyEntry(coords.getGroupId(), coords.getArtifactId()); + if (coords.getScope() != null && !coords.getScope().isEmpty()) { + childCoords.setScope(coords.getScope()); + } + if (coords.getType() != null && !coords.getType().isEmpty()) { + childCoords.setType(coords.getType()); + } + if (coords.getClassifier() != null && !coords.getClassifier().isEmpty()) { + childCoords.setClassifier(coords.getClassifier()); + } + if (coords.getOptional() != null) { + childCoords.setOptional(coords.getOptional()); + } + addDependency(childEditor, childPomFile, profile, childCoords, false); + savePomEditor(childEditor, childPomFile); + getLog().info("Added version-less dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + " to " + + childPomFile.getName()); + + // Sync in-memory model + syncInMemoryModel(getProject(), coords, false, true); + } + + /** + * Single-POM mode: adds the dependency (optionally with a version property) to the current POM. + */ + private void addSinglePom( + DependencyEntry coords, + MavenProject targetProject, + File pomFile, + boolean targetManaged, + boolean effectiveUseProperty, + String effectivePropertyName) + throws IOException, MojoFailureException { + + // Warn when adding to parent (not managed) in a multi-module project + if (!targetManaged + && targetProject.getModules() != null + && !targetProject.getModules().isEmpty()) { + getLog().warn("Adding dependency to parent POM — this will be inherited by all child modules. " + + "Use -Dmanaged to add to instead."); + } + + PomEditor editor = loadPomEditor(pomFile); + Element existing = findDependency( + editor, + profile, + coords.getGroupId(), + coords.getArtifactId(), + coords.getType(), + coords.getClassifier(), + targetManaged); + + if (existing != null) { + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + + " already exists in " + pomFile.getName() + + ". Remove it first with dependency:remove, then re-add."); + } else if (existsInResolvedModel(targetProject, coords, targetManaged)) { + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + + " already exists in the POM (using property references). " + + "Cannot safely add or update automatically. Please edit the POM manually."); + } else { + // Handle version property + if (effectiveUseProperty && effectivePropertyName != null && coords.getVersion() != null) { + editor.properties().updateProperty(true, effectivePropertyName, coords.getVersion()); + getLog().info("Added property " + effectivePropertyName + "=" + coords.getVersion()); + coords.setVersion("${" + effectivePropertyName + "}"); + } + addDependency(editor, pomFile, profile, coords, targetManaged); + getLog().info("Added dependency " + coords + " to " + pomFile.getName()); + } + + savePomEditor(editor, pomFile); + + syncInMemoryModel(targetProject, coords, targetManaged, false); + } + + /** + * Syncs the in-memory Maven model after POM modifications. + */ + private void syncInMemoryModel( + MavenProject targetProject, DependencyEntry coords, boolean targetManaged, boolean versionless) { + Model model = targetProject.getModel(); + if (model != null) { + Dependency modelDep = new Dependency(); + modelDep.setGroupId(coords.getGroupId()); + modelDep.setArtifactId(coords.getArtifactId()); + if (!versionless && coords.getVersion() != null) { + modelDep.setVersion(coords.getVersion()); + } + if (coords.getScope() != null && !coords.getScope().isEmpty()) { + modelDep.setScope(coords.getScope()); + } + if (coords.getType() != null && !coords.getType().isEmpty()) { + modelDep.setType(coords.getType()); + } + if (coords.getClassifier() != null && !coords.getClassifier().isEmpty()) { + modelDep.setClassifier(coords.getClassifier()); + } + if (coords.getOptional() != null) { + modelDep.setOptional(String.valueOf(coords.getOptional())); + } + if (targetManaged) { + DependencyManagement dm = model.getDependencyManagement(); + if (dm == null) { + dm = new DependencyManagement(); + model.setDependencyManagement(dm); + } + dm.addDependency(modelDep); + } else { + model.addDependency(modelDep); + } + } + } + + private DependencyEntry resolveCoordinates() throws MojoFailureException { + if (gav == null || gav.isEmpty()) { + throw new MojoFailureException("You must specify -Dgav=groupId:artifactId[:version]"); + } + + DependencyEntry coords; + try { + coords = DependencyEntry.parse(gav); + } catch (IllegalArgumentException e) { + throw new MojoFailureException(e.getMessage()); + } + + // Explicit parameters override GAV shorthand values + if (scope != null) { + coords.setScope(scope); + } + if (type != null) { + coords.setType(type); + } + if (classifier != null) { + coords.setClassifier(classifier); + } + + if (optional != null) { + coords.setOptional(optional); + } + + try { + coords.validate(); + } catch (IllegalArgumentException e) { + throw new MojoFailureException(e.getMessage()); + } + + return coords; + } + + private String findManagedVersion(MavenProject project, String groupId, String artifactId) { + MavenProject current = project; + while (current != null) { + DependencyManagement depMgmt = current.getDependencyManagement(); + if (depMgmt != null && depMgmt.getDependencies() != null) { + for (Dependency dep : depMgmt.getDependencies()) { + if (groupId.equals(dep.getGroupId()) && artifactId.equals(dep.getArtifactId())) { + return dep.getVersion(); + } + } + } + current = current.getParent(); + } + return null; + } + + private void addDependency( + PomEditor editor, File pomFile, String profileId, DependencyEntry coords, boolean managed) + throws MojoFailureException { + PomEditor.Dependencies dependencies = dependenciesFor(editor, pomFile, profileId); + Coordinates coordinates = Coordinates.of( + coords.getGroupId(), + coords.getArtifactId(), + coords.getVersion(), + coords.getClassifier(), + coords.getType()); + + if (managed) { + dependencies.updateManagedDependency(true, coordinates); + } else { + dependencies.updateDependency(true, coordinates); + } + + Element dependency = findDependency( + editor, + profileId, + coords.getGroupId(), + coords.getArtifactId(), + coords.getType(), + coords.getClassifier(), + managed); + if (dependency != null) { + if (coords.getScope() != null && !coords.getScope().isEmpty()) { + editor.updateOrCreateChildElement(dependency, "scope", coords.getScope()); + } + if (coords.getOptional() != null && coords.getOptional()) { + editor.updateOrCreateChildElement(dependency, "optional", "true"); + } + } + } + + private PomEditor.Dependencies dependenciesFor(PomEditor editor, File pomFile, String profileId) + throws MojoFailureException { + PomEditor.Dependencies dependencies = editor.dependencies(); + if (profileId == null || profileId.isEmpty()) { + return dependencies; + } + Element profileElement = editor.profiles().findProfile(profileId); + if (profileElement == null) { + throw new MojoFailureException("Profile '" + profileId + "' not found in " + pomFile.getName() + "."); + } + return dependencies.forProfile(profileElement); + } + + private static PomEditor loadPomEditor(File pomFile) throws IOException { + try { + String content = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + String upper = content.toUpperCase(Locale.ROOT); + if (upper.contains(" root element but found <" + rootName + ">"); + } + return editor; + } catch (RuntimeException e) { + throw new IOException("Failed to parse POM file: " + pomFile, e); + } + } + + private static void savePomEditor(PomEditor editor, File pomFile) throws IOException { + Path target = pomFile.toPath(); + File tempFile = File.createTempFile("pom", ".xml.tmp", pomFile.getParentFile()); + boolean success = false; + try { + try (OutputStream os = Files.newOutputStream(tempFile.toPath())) { + editor.document().toXml(os); + } + Files.move(tempFile.toPath(), target, StandardCopyOption.REPLACE_EXISTING); + success = true; + } finally { + if (!success) { + Files.deleteIfExists(tempFile.toPath()); + } + } + } + + private static Element findDependency( + PomEditor editor, + String profileId, + String groupId, + String artifactId, + String type, + String classifier, + boolean managed) { + if (groupId == null || artifactId == null) { + throw new IllegalArgumentException("groupId and artifactId must not be null"); + } + Element depsElement = getDependenciesElement(editor, profileId, managed); + if (depsElement == null) { + return null; + } + + Coordinates coordinates = Coordinates.of(groupId, artifactId, null, classifier, type); + return depsElement + .childElements("dependency") + .filter(coordinates.predicateGATC()) + .findFirst() + .orElse(null); + } + + private static Element getDependenciesElement(PomEditor editor, String profileId, boolean managed) { + Element context = editor.root(); + if (profileId != null && !profileId.isEmpty()) { + context = editor.profiles().findProfile(profileId); + if (context == null) { + return null; + } + } + + if (managed) { + Element depMgmt = editor.findChildElement(context, "dependencyManagement"); + return depMgmt != null ? editor.findChildElement(depMgmt, "dependencies") : null; + } + return editor.findChildElement(context, "dependencies"); + } + + private static List getDependencyVersions(PomEditor editor, boolean managed) { + List versions = new ArrayList<>(); + Element depsElement = getDependenciesElement(editor, null, managed); + if (depsElement == null) { + return versions; + } + depsElement.childElements("dependency").forEach(dep -> { + String version = dep.childTextOr("version", null); + if (version != null && !version.isEmpty()) { + versions.add(version); + } + }); + return versions; + } + + private static long getDependencyCount(PomEditor editor, boolean managed) { + Element depsElement = getDependenciesElement(editor, null, managed); + return depsElement != null ? depsElement.childElements("dependency").count() : 0; + } + + // --- Convention detection --- + + /** + * Detects the project's dependency management conventions by analyzing existing dependencies. + */ + private Conventions detectConventions(MavenProject project) throws MojoExecutionException { + Conventions conv = new Conventions(); + + File pomFile = project.getFile(); + if (pomFile == null) { + return conv; + } + + try { + PomEditor editor = loadPomEditor(pomFile); + + // Analyze managed dependency usage: count deps with/without version + long totalDeps = getDependencyCount(editor, false); + List depVersions = getDependencyVersions(editor, false); + long depsWithVersion = depVersions.size(); + + if (totalDeps > 0 && depsWithVersion < totalDeps / 2.0) { + // Majority of deps are version-less → use managed deps + conv.useManaged = true; + getLog().debug("Convention detected: majority of dependencies are version-less (useManaged=true)"); + } + + // Find the POM that has + conv.managedPomFile = findManagedDepsPom(project); + + // Analyze property patterns from child POM versions + List allVersions = new java.util.ArrayList<>(depVersions); + allVersions.addAll(getDependencyVersions(editor, true)); + + // If child has mostly version-less deps, also scan the parent POM for property patterns + if (conv.managedPomFile != null + && !conv.managedPomFile.getAbsolutePath().equals(pomFile.getAbsolutePath())) { + PomEditor parentEditor = loadPomEditor(conv.managedPomFile); + allVersions.addAll(getDependencyVersions(parentEditor, true)); + allVersions.addAll(getDependencyVersions(parentEditor, false)); + } + + // Count property references vs literal versions + long propertyRefs = allVersions.stream() + .filter(v -> v.startsWith("${") && v.endsWith("}")) + .count(); + if (!allVersions.isEmpty() && propertyRefs > allVersions.size() / 2.0) { + conv.useProperty = true; + getLog().debug("Convention detected: majority of versions use property references (useProperty=true)"); + + // Detect property naming pattern + conv.pattern = detectPropertyPattern(allVersions); + if (conv.pattern != null) { + getLog().debug("Convention detected: property naming pattern=" + conv.pattern); + } + } + } catch (IOException e) { + getLog().debug("Could not analyze POM conventions: " + e.getMessage()); + } + + return conv; + } + + /** + * Walks the parent chain to find the nearest POM that declares {@code }. + */ + private File findManagedDepsPom(MavenProject project) { + MavenProject current = project.getParent(); + while (current != null) { + File pf = current.getFile(); + if (pf != null) { + DependencyManagement dm = current.getOriginalModel().getDependencyManagement(); + if (dm != null + && dm.getDependencies() != null + && !dm.getDependencies().isEmpty()) { + return pf; + } + } + current = current.getParent(); + } + return null; + } + + private static final Pattern PROPERTY_REF = Pattern.compile("^\\$\\{(.+)}$"); + + /** + * Property naming conventions detected in existing POM files. + */ + enum PropertyPattern { + /** {@code artifactId.version} (e.g., {@code guava.version}) */ + DOT_VERSION { + @Override + String toPropertyName(String artifactId) { + return artifactId + ".version"; + } + + @Override + boolean matches(String propName) { + return propName.endsWith(".version"); + } + }, + /** {@code artifactId-version} (e.g., {@code guava-version}) */ + DASH_VERSION { + @Override + String toPropertyName(String artifactId) { + return artifactId + "-version"; + } + + @Override + boolean matches(String propName) { + return propName.endsWith("-version"); + } + }, + /** {@code artifactIdVersion} (camelCase, e.g., {@code guavaVersion}) */ + CAMEL_VERSION { + @Override + String toPropertyName(String artifactId) { + return artifactId + "Version"; + } + + @Override + boolean matches(String propName) { + return propName.endsWith("Version"); + } + }, + /** {@code version.artifactId} (prefix, e.g., {@code version.guava}) */ + VERSION_PREFIX { + @Override + String toPropertyName(String artifactId) { + return "version." + artifactId; + } + + @Override + boolean matches(String propName) { + return propName.startsWith("version."); + } + }; + + abstract String toPropertyName(String artifactId); + + abstract boolean matches(String propName); + } + + /** + * Analyzes property reference names to detect the naming convention. + * + * @return the most common {@link PropertyPattern}, or {@code null} if no clear pattern is found + */ + static PropertyPattern detectPropertyPattern(List versions) { + Map patternCounts = new HashMap<>(); + for (String version : versions) { + Matcher m = PROPERTY_REF.matcher(version); + if (m.matches()) { + String propName = m.group(1); + for (PropertyPattern pp : PropertyPattern.values()) { + if (pp.matches(propName)) { + patternCounts.merge(pp, 1, Integer::sum); + break; + } + } + } + } + if (patternCounts.isEmpty()) { + return null; + } + return patternCounts.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); + } + + /** + * Holds the detected conventions for the project. + */ + static class Conventions { + boolean useManaged; + boolean useProperty; + PropertyPattern pattern; + File managedPomFile; + + /** + * Derives a property name for the given dependency based on the detected pattern. + */ + String derivePropertyName(DependencyEntry coords) { + PropertyPattern effective = pattern != null ? pattern : PropertyPattern.DOT_VERSION; + return effective.toPropertyName(coords.getArtifactId()); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/dependency/RemoveDependencyMojo.java b/src/main/java/org/apache/maven/plugins/dependency/RemoveDependencyMojo.java new file mode 100644 index 000000000..1bfd4a3e4 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/dependency/RemoveDependencyMojo.java @@ -0,0 +1,304 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency; + +import javax.inject.Inject; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +import eu.maveniverse.domtrip.Document; +import eu.maveniverse.domtrip.Element; +import eu.maveniverse.domtrip.maven.Coordinates; +import eu.maveniverse.domtrip.maven.PomEditor; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.model.Model; +import org.apache.maven.model.io.xpp3.MavenXpp3Reader; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.dependency.pom.DependencyEntry; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.XmlStreamReader; +import org.codehaus.plexus.util.xml.pull.XmlPullParserException; +import org.sonatype.plexus.build.incremental.BuildContext; + +/** + * Removes a dependency from the project's {@code pom.xml}. + * Supports removing from {@code } or {@code }. + * + *

Matching uses groupId, artifactId, type, and classifier for precision. + * If the dependency exists in Maven's resolved model but uses property references + * in the raw POM, a clear error directs the user to edit manually.

+ * When removing a managed dependency from a parent POM, warns if child modules + * reference it without an explicit version. + * + * @since 3.11.0 + */ +@Mojo(name = "remove", requiresProject = true, threadSafe = true) +public class RemoveDependencyMojo extends AbstractDependencyMojo { + + /** + * Dependency coordinates: {@code groupId:artifactId[:version]} + * or {@code groupId:artifactId[:extension[:classifier]]:version}. + * Only groupId and artifactId are required. Type and classifier, if provided, + * are used for precise matching when multiple dependency variants exist + * (e.g., jar vs test-jar). + */ + @Parameter(property = "gav") + private String gav; + + /** + * When {@code true}, remove from {@code } instead of {@code }. + */ + @Parameter(property = "managed", defaultValue = "false") + private boolean managed; + + /** + * Dependency type for precise matching (e.g., {@code pom}, {@code war}, {@code test-jar}). + * When not specified, defaults to {@code "jar"}. + */ + @Parameter(property = "type") + private String type; + + /** + * Dependency classifier for precise matching (e.g., {@code sources}, {@code javadoc}, {@code tests}). + */ + @Parameter(property = "classifier") + private String classifier; + + /** + * Target a specific Maven profile by its {@code }. When set, the dependency is removed + * from the profile's {@code } or {@code } section. + * The profile must already exist in the POM. + */ + @Parameter(property = "profile") + private String profile; + + @Inject + public RemoveDependencyMojo(MavenSession session, BuildContext buildContext, MavenProject project) { + super(session, buildContext, project); + } + + @Override + protected void doExecute() throws MojoExecutionException, MojoFailureException { + DependencyEntry coords = resolveCoordinates(); + + boolean targetManaged = managed; + MavenProject targetProject = getProject(); + File pomFile = targetProject.getFile(); + if (pomFile == null) { + throw new MojoExecutionException("Cannot remove dependency: project has no POM file to modify."); + } + + // Safety check for managed dependency removal in parent POM + if (targetManaged + && targetProject.getModules() != null + && !targetProject.getModules().isEmpty()) { + checkChildModuleDependencies(targetProject, coords.getGroupId(), coords.getArtifactId()); + } + + try { + PomEditor editor = loadPomEditor(pomFile); + PomEditor.Dependencies dependencies = dependenciesFor(editor, pomFile); + Coordinates coordinates = Coordinates.of( + coords.getGroupId(), coords.getArtifactId(), null, coords.getClassifier(), coords.getType()); + boolean removed = targetManaged + ? dependencies.deleteManagedDependency(coordinates) + : dependencies.deleteDependency(coordinates); + + if (!removed) { + // Cross-reference with resolved model to detect property-interpolated coords + if (existsInResolvedModel(targetProject, coords, targetManaged)) { + String section = targetManaged ? "" : ""; + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + + coords.getArtifactId() + + " exists in " + section + " but uses property references in the POM. " + + "Please remove it manually."); + } + String section = targetManaged ? "" : ""; + throw new MojoFailureException("Dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + + " not found in " + section + "."); + } + + savePomEditor(editor, pomFile); + + // Sync in-memory model so chained goals see the change + Model model = targetProject.getModel(); + if (model != null) { + String removeType = + (coords.getType() != null && !coords.getType().isEmpty()) ? coords.getType() : "jar"; + String removeClassifier = (coords.getClassifier() != null + && !coords.getClassifier().isEmpty()) + ? coords.getClassifier() + : ""; + if (targetManaged) { + DependencyManagement dm = model.getDependencyManagement(); + if (dm != null && dm.getDependencies() != null) { + dm.getDependencies() + .removeIf(d -> coords.getGroupId().equals(d.getGroupId()) + && coords.getArtifactId().equals(d.getArtifactId()) + && removeType.equals(d.getType() != null ? d.getType() : "jar") + && removeClassifier.equals(d.getClassifier() != null ? d.getClassifier() : "")); + } + } else if (model.getDependencies() != null) { + model.getDependencies() + .removeIf(d -> coords.getGroupId().equals(d.getGroupId()) + && coords.getArtifactId().equals(d.getArtifactId()) + && removeType.equals(d.getType() != null ? d.getType() : "jar") + && removeClassifier.equals(d.getClassifier() != null ? d.getClassifier() : "")); + } + } + + getLog().info("Removed dependency " + coords.getGroupId() + ":" + coords.getArtifactId() + " from " + + pomFile.getName()); + } catch (IOException e) { + throw new MojoExecutionException("Failed to modify POM file: " + pomFile, e); + } + } + + private PomEditor.Dependencies dependenciesFor(PomEditor editor, File pomFile) throws MojoFailureException { + PomEditor.Dependencies dependencies = editor.dependencies(); + if (profile == null || profile.isEmpty()) { + return dependencies; + } + Element profileElement = editor.profiles().findProfile(profile); + if (profileElement == null) { + throw new MojoFailureException("Profile '" + profile + "' not found in " + pomFile.getName() + "."); + } + return dependencies.forProfile(profileElement); + } + + private static PomEditor loadPomEditor(File pomFile) throws IOException { + try { + String content = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + String upper = content.toUpperCase(Locale.ROOT); + if (upper.contains(" root element but found <" + rootName + ">"); + } + return editor; + } catch (RuntimeException e) { + throw new IOException("Failed to parse POM file: " + pomFile, e); + } + } + + private static void savePomEditor(PomEditor editor, File pomFile) throws IOException { + Path target = pomFile.toPath(); + File tempFile = File.createTempFile("pom", ".xml.tmp", pomFile.getParentFile()); + boolean success = false; + try { + try (OutputStream os = Files.newOutputStream(tempFile.toPath())) { + editor.document().toXml(os); + } + Files.move(tempFile.toPath(), target, StandardCopyOption.REPLACE_EXISTING); + success = true; + } finally { + if (!success) { + Files.deleteIfExists(tempFile.toPath()); + } + } + } + + private DependencyEntry resolveCoordinates() throws MojoFailureException { + if (gav == null || gav.isEmpty()) { + throw new MojoFailureException("You must specify -Dgav=groupId:artifactId"); + } + + DependencyEntry coords; + try { + coords = DependencyEntry.parse(gav); + } catch (IllegalArgumentException e) { + throw new MojoFailureException(e.getMessage()); + } + + // Explicit parameters override GAV shorthand values + if (type != null) { + coords.setType(type); + } + if (classifier != null) { + coords.setClassifier(classifier); + } + + try { + coords.validate(); + } catch (IllegalArgumentException e) { + throw new MojoFailureException(e.getMessage()); + } + + return coords; + } + + private void checkChildModuleDependencies(MavenProject parentProject, String depGroupId, String depArtifactId) + throws MojoExecutionException { + if (parentProject.getBasedir() == null) { + getLog().debug("Parent project basedir is null, skipping child module dependency check"); + return; + } + StringBuilder affected = new StringBuilder(); + MavenXpp3Reader pomReader = new MavenXpp3Reader(); + + for (String moduleName : parentProject.getModules()) { + File moduleDir = new File(parentProject.getBasedir(), moduleName); + File modulePom = new File(moduleDir, "pom.xml"); + if (!modulePom.exists()) { + continue; + } + try (Reader reader = new XmlStreamReader(modulePom)) { + Model model = pomReader.read(reader); + if (model.getDependencies() != null) { + for (Dependency dep : model.getDependencies()) { + if (depGroupId.equals(dep.getGroupId()) + && depArtifactId.equals(dep.getArtifactId()) + && (dep.getVersion() == null || dep.getVersion().isEmpty())) { + if (affected.length() > 0) { + affected.append(", "); + } + affected.append(moduleName); + break; + } + } + } + } catch (IOException | XmlPullParserException e) { + getLog().debug("Could not read module POM: " + modulePom + " - " + e.getMessage()); + } + } + + if (affected.length() > 0) { + getLog().warn("The following child modules depend on " + depGroupId + ":" + depArtifactId + + " without an explicit version and will break: [" + affected + "]. Proceeding with removal."); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/dependency/pom/DependencyEntry.java b/src/main/java/org/apache/maven/plugins/dependency/pom/DependencyEntry.java new file mode 100644 index 000000000..e69e5e1d5 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/dependency/pom/DependencyEntry.java @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency.pom; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Represents parsed Maven dependency coordinates (GAV + scope/type/classifier). + * Supports parsing from a colon-separated string in the format: + * {@code groupId:artifactId[:version]} or + * {@code groupId:artifactId[:extension[:classifier]]:version}. + * + *

This follows the standard Maven coordinate convention used by + * {@code org.eclipse.aether.artifact.DefaultArtifact}. Scope and optional + * are not part of coordinates and must be specified as separate parameters.

+ * + *

Design note: This class exists because no single class on the + * classpath covers all requirements. {@code DefaultArtifact} (Aether) provides GAV + * parsing but requires a version (throws on {@code g:a}) and lacks scope/optional. + * {@code Dependency} (maven-model) has all fields but no coordinate string parser. + * This class bridges both: it parses {@code g:a} (needed by {@code dependency:remove}), + * carries scope/optional, and validates scope against Maven's known values.

+ * + * @since 3.11.0 + */ +public class DependencyEntry { + + private static final Set VALID_SCOPES = + new HashSet<>(Arrays.asList("compile", "provided", "runtime", "test", "system", "import")); + + private String groupId; + private String artifactId; + private String version; + private String scope; + private String type; + private String classifier; + private Boolean optional; + + public DependencyEntry() {} + + public DependencyEntry(String groupId, String artifactId) { + this.groupId = groupId; + this.artifactId = artifactId; + } + + /** + * Parses a colon-separated coordinate string following Maven conventions. + * + *

Supported formats:

+ *
    + *
  • {@code groupId:artifactId} — minimum
  • + *
  • {@code groupId:artifactId:version}
  • + *
  • {@code groupId:artifactId:extension:version}
  • + *
  • {@code groupId:artifactId:extension:classifier:version}
  • + *
+ * + * @param gav the coordinate string + * @return the parsed coordinates + * @throws IllegalArgumentException if the string has fewer than 2 or more than 5 segments + */ + public static DependencyEntry parse(String gav) { + if (gav == null || gav.trim().isEmpty()) { + throw new IllegalArgumentException("GAV string must not be null or empty"); + } + String[] tokens = gav.split(":", -1); + if (tokens.length < 2 || tokens.length > 5) { + throw new IllegalArgumentException( + "Invalid GAV format: '" + gav + + "'. Expected groupId:artifactId[:version] or groupId:artifactId[:extension[:classifier]]:version"); + } + DependencyEntry coords = new DependencyEntry(); + coords.groupId = tokens[0].trim(); + coords.artifactId = tokens[1].trim(); + if (coords.groupId.isEmpty()) { + throw new IllegalArgumentException("Invalid GAV format: '" + gav + "'. groupId must not be empty."); + } + if (coords.artifactId.isEmpty()) { + throw new IllegalArgumentException("Invalid GAV format: '" + gav + "'. artifactId must not be empty."); + } + switch (tokens.length) { + case 3: + // g:a:v + if (!tokens[2].trim().isEmpty()) { + coords.version = tokens[2].trim(); + } + break; + case 4: + // g:a:ext:v + if (!tokens[2].trim().isEmpty()) { + coords.type = tokens[2].trim(); + } + if (!tokens[3].trim().isEmpty()) { + coords.version = tokens[3].trim(); + } + break; + case 5: + // g:a:ext:cls:v + if (!tokens[2].trim().isEmpty()) { + coords.type = tokens[2].trim(); + } + if (!tokens[3].trim().isEmpty()) { + coords.classifier = tokens[3].trim(); + } + if (!tokens[4].trim().isEmpty()) { + coords.version = tokens[4].trim(); + } + break; + default: + break; + } + return coords; + } + + /** + * Validates that required fields are present. + * + * @throws IllegalArgumentException if groupId or artifactId is missing + */ + public void validate() { + if (groupId == null || groupId.isEmpty()) { + throw new IllegalArgumentException("groupId must not be null or empty"); + } + if (artifactId == null || artifactId.isEmpty()) { + throw new IllegalArgumentException("artifactId must not be null or empty"); + } + if (scope != null && !scope.isEmpty() && !VALID_SCOPES.contains(scope)) { + throw new IllegalArgumentException("Invalid scope: '" + scope + "'. Valid scopes are: " + VALID_SCOPES); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(groupId).append(':').append(artifactId); + if (version != null) { + sb.append(':').append(version); + } + StringBuilder details = new StringBuilder(); + if (type != null && !"jar".equals(type)) { + details.append("type=").append(type); + } + if (classifier != null && !classifier.isEmpty()) { + if (details.length() > 0) { + details.append(", "); + } + details.append("classifier=").append(classifier); + } + if (scope != null) { + if (details.length() > 0) { + details.append(", "); + } + details.append("scope=").append(scope); + } + if (details.length() > 0) { + sb.append(" [").append(details).append(']'); + } + return sb.toString(); + } + + // Getters and setters + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public String getArtifactId() { + return artifactId; + } + + public void setArtifactId(String artifactId) { + this.artifactId = artifactId; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getClassifier() { + return classifier; + } + + public void setClassifier(String classifier) { + this.classifier = classifier; + } + + public Boolean getOptional() { + return optional; + } + + public void setOptional(Boolean optional) { + this.optional = optional; + } +} diff --git a/src/main/java/org/apache/maven/plugins/dependency/resolvers/ResolvePluginsMojo.java b/src/main/java/org/apache/maven/plugins/dependency/resolvers/ResolvePluginsMojo.java index 1503bc0fb..b48998215 100644 --- a/src/main/java/org/apache/maven/plugins/dependency/resolvers/ResolvePluginsMojo.java +++ b/src/main/java/org/apache/maven/plugins/dependency/resolvers/ResolvePluginsMojo.java @@ -153,7 +153,6 @@ public ResolvePluginsMojo( @Override protected void doExecute() throws MojoExecutionException { try { - // ideally this should either be DependencyCoordinates or DependencyNode final Set plugins = getProjectPlugins(); StringBuilder sb = new StringBuilder(); diff --git a/src/site/apt/examples/managing-dependencies.apt.vm b/src/site/apt/examples/managing-dependencies.apt.vm new file mode 100644 index 000000000..8f384e9c9 --- /dev/null +++ b/src/site/apt/examples/managing-dependencies.apt.vm @@ -0,0 +1,231 @@ +~~ Licensed to the Apache Software Foundation (ASF) under one +~~ or more contributor license agreements. See the NOTICE file +~~ distributed with this work for additional information +~~ regarding copyright ownership. The ASF licenses this file +~~ to you under the Apache License, Version 2.0 (the +~~ "License"); you may not use this file except in compliance +~~ with the License. You may obtain a copy of the License at +~~ +~~ http://www.apache.org/licenses/LICENSE-2.0 +~~ +~~ Unless required by applicable law or agreed to in writing, +~~ software distributed under the License is distributed on an +~~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +~~ KIND, either express or implied. See the License for the +~~ specific language governing permissions and limitations +~~ under the License. + + ------ + Managing Dependencies + ------ + Bruno Borges + ------ + 2025-06-01 + ------ + +Managing Dependencies + + Starting with version 3.11.0, the Maven Dependency Plugin provides two goals + for managing dependencies directly from the command line: <<>> + and <<>>. + +%{toc|fromDepth=2} + +* Adding Dependencies + + The <<>> goal adds a dependency to your project's <<>>. + +** Basic Usage + + You can add a dependency by specifying its coordinates with the <<>> parameter: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 ++---+ + + The <<>> parameter accepts the format <<>>. + Scope must be specified separately with <<<-Dscope>>>: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dscope=test ++---+ + +** Adding to dependencyManagement + + Use the <<<-Dmanaged>>> flag to add a dependency to the <<<\>>> section + instead of <<<\>>>: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dmanaged ++---+ + +** Adding a BOM Import + + To import a BOM (Bill of Materials) into <<<\>>>, specify the type and scope explicitly: + ++---+ +mvn dependency:add -Dgav=org.springframework.boot:spring-boot-dependencies:pom:3.2.0 -Dscope=import -Dmanaged ++---+ + + This inserts a dependency with <<>> and <<>> into <<<\>>>. + +** Version Inference + + When adding to <<<\>>> and the version is already managed by a parent POM's + <<<\>>>, you can omit the version: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3 ++---+ + + The goal will detect the managed version and omit the <<<\>>> element from the inserted + dependency, following Maven convention. If no managed version is found, the goal fails with + a descriptive error. + +** Additional Parameters + + You can also specify <<>> and <<>> explicitly. These override any values + provided in the <<>> shorthand: + ++---+ +mvn dependency:add -Dgav=com.example:lib:1.0 -Dtype=test-jar +mvn dependency:add -Dgav=com.example:lib:1.0 -Dclassifier=sources ++---+ + +** Duplicate Detection + + If the dependency already exists in the POM (matching on <<>>, <<>>, + <<>>, and <<>>), the goal fails with a descriptive error directing you to + remove the existing dependency first: + ++---+ +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dscope=test ++---+ + +** Optional Dependencies + + Mark a dependency as optional using the <<>> parameter: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Doptional=true ++---+ + +** Scope Validation + + The <<>> parameter is validated against Maven's known scope values: + <<>>, <<>>, <<>>, <<>>, <<>>, and <<>>. + Invalid scope values are rejected with an error. + +** Dependencies Using Property References + + Some POMs declare dependencies using Maven property references for the <<>> or + <<>>, for example: + ++---+ + + ${my.group} + lib + ++---+ + + When <<>> detects that a dependency with the same coordinates already exists + in the resolved (effective) model but cannot be found in the raw XML, it means the dependency + is declared using property interpolation. In this case, the goal <> with an error message + explaining that the dependency cannot be safely added or updated automatically: + ++---+ +[ERROR] Dependency com.example:lib already exists in the POM (using property references). +Cannot safely add or update automatically. Please edit the POM manually. ++---+ + + This safety check prevents <<>> from inserting a duplicate entry that would + conflict with the property-based declaration. The same check applies to <<>>. + +** Multi-Module Projects + + To add a dependency to a specific child module from the root of a multi-module project, + use Maven's built-in <<<-pl>>> (project list) flag: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -pl my-service ++---+ + +** Adding to a Profile + + To add a dependency to a specific Maven profile, use the <<<-Dprofile>>> parameter. + The profile must already exist in the POM: + ++---+ +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dprofile=dev ++---+ + +* Removing Dependencies + + The <<>> goal removes a dependency from your project's <<>>. + +** Basic Usage + + Remove a dependency using the <<>> parameter: + ++---+ +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 ++---+ + + Only <<>> and <<>> are required to identify the dependency to remove. + +** Type and Classifier Matching + + Maven allows the same <<>> to appear multiple times in a POM + with different types or classifiers (e.g., both the main jar and a test-jar). These + are distinct dependencies. + + When no <<<-Dtype>>> or <<<-Dclassifier>>> is specified, <<>> targets + the default variant (<<>>, no classifier). To remove a specific variant, use + <<<-Dtype>>> and/or <<<-Dclassifier>>>: + ++---+ +mvn dependency:remove -Dgav=com.example:lib -Dtype=test-jar +mvn dependency:remove -Dgav=com.example:lib -Dclassifier=sources ++---+ + + Explicit <<<-Dtype>>> and <<<-Dclassifier>>> override any values from the <<<-Dgav>>> parameter. + + Similarly, <<>> uses <<>> matching for + duplicate detection. Adding <<>> will succeed even if + <<>> already exists, since they are distinct dependencies. + +** Removing a BOM Import + + To remove a BOM import from <<<\>>>, specify the type and use <<<-Dmanaged>>>: + ++---+ +mvn dependency:remove -Dgav=org.springframework.boot:spring-boot-dependencies -Dtype=pom -Dmanaged ++---+ + +** Removing from dependencyManagement + + To remove a dependency from <<<\>>> instead of <<<\>>>: + ++---+ +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 -Dmanaged ++---+ + +** Removing from a Specific Module + + In a multi-module project, use Maven's <<<-pl>>> flag to target a specific child module: + ++---+ +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 -pl my-service ++---+ + + If the dependency is not found, the goal fails with a descriptive error message. + +** Removing from a Profile + + To remove a dependency from a specific Maven profile: + ++---+ +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 -Dprofile=dev ++---+ + diff --git a/src/site/apt/index.apt.vm b/src/site/apt/index.apt.vm index b44c07f56..9a5db684f 100644 --- a/src/site/apt/index.apt.vm +++ b/src/site/apt/index.apt.vm @@ -34,6 +34,10 @@ ${project.name} The Dependency plugin has several goals: + *{{{./add-mojo.html}dependency:add}} adds a dependency to the project's <<>> from the command line. + Supports GAV shorthand, version inference from <<<\>>>, BOM imports, profile targeting, and more. + See {{{./examples/managing-dependencies.html}Managing Dependencies}}. + *{{{./analyze-mojo.html}dependency:analyze}} analyzes the dependencies of this project and determines which are: used and declared; used and undeclared; unused and declared. @@ -89,6 +93,10 @@ ${project.name} *{{{./purge-local-repository-mojo.html}dependency:purge-local-repository}} tells Maven to clear dependency artifact files out of the local repository, and optionally re-resolve them. + *{{{./remove-mojo.html}dependency:remove}} removes a dependency from the project's <<>> from the command line. + Supports <<<\>>>, BOM imports, profile targeting, and child module safety checks. + See {{{./examples/managing-dependencies.html}Managing Dependencies}}. + *{{{./resolve-mojo.html}dependency:resolve}} tells Maven to resolve all dependencies and displays the version. <> @@ -151,6 +159,8 @@ ${project.name} * {{{./examples/filtering-the-dependency-tree.html}Filtering the Dependency Tree}} + * {{{./examples/managing-dependencies.html}Managing Dependencies}} + * {{{./examples/purging-local-repository.html}Purging the local repository}} * {{{./examples/tree-mojo.html}Tree Mojo}} diff --git a/src/site/apt/usage.apt.vm b/src/site/apt/usage.apt.vm index 6519ea04d..7c1ef0aaf 100644 --- a/src/site/apt/usage.apt.vm +++ b/src/site/apt/usage.apt.vm @@ -714,3 +714,46 @@ mvn dependency:analyze-exclusions [WARNING] - javax.annotation:javax.annotation-api [WARNING] - javax.activation:javax.activation-api +---+ + +* <<>> + + This goal adds a dependency to the project's <<>> directly from the command line. + If the dependency already exists, the goal fails with a descriptive error directing you to + remove it first. For detailed examples, see + {{{./examples/managing-dependencies.html}Managing Dependencies}}. + +--- +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dscope=test +--- + + Add to <<<\>>>: + +--- +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -Dmanaged +--- + + Add a BOM import: + +--- +mvn dependency:add -Dgav=org.springframework.boot:spring-boot-dependencies:pom:3.2.0 -Dscope=import -Dmanaged +--- + + In multi-module projects, use Maven's <<<-pl>>> flag to target a specific module: + +--- +mvn dependency:add -Dgav=org.apache.commons:commons-lang3:3.17.0 -pl my-service +--- + +* <<>> + + This goal removes a dependency from the project's <<>> directly from the command line. + For detailed examples, see {{{./examples/managing-dependencies.html}Managing Dependencies}}. + +--- +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 +mvn dependency:remove -Dgav=org.apache.commons:commons-lang3 -Dmanaged +--- + + When removing a managed dependency from a parent POM, the goal warns if child modules still + reference that dependency without an explicit version. diff --git a/src/site/site.xml b/src/site/site.xml index 969c6875a..66ab52122 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -44,6 +44,7 @@ under the License. + diff --git a/src/test/java/org/apache/maven/plugins/dependency/AddDependencyMojoTest.java b/src/test/java/org/apache/maven/plugins/dependency/AddDependencyMojoTest.java new file mode 100644 index 000000000..e66f38bfa --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/dependency/AddDependencyMojoTest.java @@ -0,0 +1,581 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.model.Model; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.dependency.pom.DependencyEntry; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.sonatype.plexus.build.incremental.BuildContext; + +import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AddDependencyMojoTest { + + @TempDir + File tempDir; + + private MavenSession session; + private BuildContext buildContext; + private MavenProject project; + private AddDependencyMojo mojo; + + @BeforeEach + void setUp() throws Exception { + session = mock(MavenSession.class); + buildContext = mock(BuildContext.class); + project = mock(MavenProject.class); + when(buildContext.isIncremental()).thenReturn(false); + + mojo = new AddDependencyMojo(session, buildContext, project); + } + + private File createTempPom(String content) throws IOException { + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return pomFile; + } + + @Test + void propertyInterpolatedDependencyBlocksAdd() throws Exception { + // POM has a dependency using a property reference that PomEditor can't match literally + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " ${some.property}\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + + // Original model returns the interpolated dependency + Model originalModel = new Model(); + Dependency dep = new Dependency(); + dep.setGroupId("com.example"); + dep.setArtifactId("lib"); + dep.setVersion("1.0"); + originalModel.addDependency(dep); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Collections.emptyList()); + + setVariableValueToObject(mojo, "gav", "com.example:lib:2.0"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("property references")); + } + + @Test + void inheritedDependencyDoesNotBlockAddToChild() throws Exception { + // Child POM has no dependencies declared + String pom = "\n" + + "\n" + + " com.example\n" + + " child\n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + + // Original model has NO dependencies (the dependency is inherited, not declared) + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Collections.emptyList()); + + setVariableValueToObject(mojo, "gav", "junit:junit:4.13"); + + // Should succeed — inherited dep should not block adding to child + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("junit")); + } + + @Test + void propertyInterpolatedDependencyBlocksUpdate() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " ${my.group}\n" + + " lib\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + + Model originalModel = new Model(); + Dependency dep = new Dependency(); + dep.setGroupId("com.example"); + dep.setArtifactId("lib"); + originalModel.addDependency(dep); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Collections.emptyList()); + + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + + // Should still block — property-interpolated deps cannot be safely updated + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("property references")); + } + + @Test + void managedDependencyOriginalModelCrossReference() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " ${parent.group}\n" + + " managed-lib\n" + + " 1.0\n" + + " \n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + + Model originalModel = new Model(); + DependencyManagement depMgmt = new DependencyManagement(); + Dependency dep = new Dependency(); + dep.setGroupId("com.example"); + dep.setArtifactId("managed-lib"); + dep.setVersion("1.0"); + depMgmt.addDependency(dep); + originalModel.setDependencyManagement(depMgmt); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Collections.emptyList()); + + setVariableValueToObject(mojo, "gav", "com.example:managed-lib:2.0"); + setVariableValueToObject(mojo, "managed", true); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("property references")); + } + + @Test + void profileNotFoundThrowsClearError() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + "\n"; + when(project.getFile()).thenReturn(createTempPom(pom)); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + setVariableValueToObject(mojo, "profile", "nonexistent"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("Profile 'nonexistent' not found")); + } + + @Test + void profileNotFoundWhenNoProfilesSectionThrowsClearError() throws Exception { + String pom = + "\n" + "\n" + " \n" + "\n"; + when(project.getFile()).thenReturn(createTempPom(pom)); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + setVariableValueToObject(mojo, "profile", "dev"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("Profile 'dev' not found")); + } + + @Test + void addDependencyToProfileSucceeds() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + setVariableValueToObject(mojo, "profile", "dev"); + + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.example")); + assertTrue(result.contains("dev")); + } + + @Test + void basicAddWithGavShorthand() throws Exception { + String pom = + "\n" + "\n" + " \n" + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + setVariableValueToObject(mojo, "scope", "test"); + + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.example"), result); + assertTrue(result.contains("lib"), result); + assertTrue(result.contains("1.0"), result); + assertTrue(result.contains("test"), result); + } + + @Test + void duplicateDependencyFailsWithError() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + Dependency d = new Dependency(); + d.setGroupId("com.example"); + d.setArtifactId("lib"); + originalModel.addDependency(d); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib:2.0"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("already exists"), ex.getMessage()); + } + + @Test + void managedWithoutVersionFails() throws Exception { + String pom = "\n" + "\n" + "\n"; + when(project.getFile()).thenReturn(createTempPom(pom)); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "managed", true); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("Version is required")); + } + + @Test + void versionInferredFromParentDependencyManagement() throws Exception { + String pom = + "\n" + "\n" + " \n" + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Collections.emptyList()); + + // Parent has the dependency managed + MavenProject parentProject = mock(MavenProject.class); + DependencyManagement parentDepMgmt = new DependencyManagement(); + Dependency managed = new Dependency(); + managed.setGroupId("com.example"); + managed.setArtifactId("lib"); + managed.setVersion("3.0.0"); + parentDepMgmt.addDependency(managed); + when(parentProject.getDependencyManagement()).thenReturn(parentDepMgmt); + when(parentProject.getParent()).thenReturn(null); + when(project.getParent()).thenReturn(parentProject); + when(project.getDependencyManagement()).thenReturn(null); + + // No version provided — should be inferred + setVariableValueToObject(mojo, "gav", "com.example:lib"); + + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.example")); + assertTrue(result.contains("lib")); + // Version should NOT be in the POM — it's managed by parent + assertFalse(result.contains(""), "version should be omitted for managed deps"); + } + + @Test + void parentPomAddWarnsAboutInheritance() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " child-a\n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Arrays.asList("child-a")); + + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.example")); + } + + @Test + void missingGavFails() throws Exception { + when(project.getFile()).thenReturn(createTempPom("")); + + // No gav set + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("You must specify -Dgav=groupId:artifactId[:version]")); + } + + @Test + void malformedGavFails() throws Exception { + when(project.getFile()).thenReturn(createTempPom("")); + + setVariableValueToObject(mojo, "gav", "only-one-part"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("GAV")); + } + + @Test + void explicitParamsOverrideGav() throws Exception { + String pom = + "\n" + "\n" + " \n" + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + when(project.getModules()).thenReturn(Collections.emptyList()); + + // GAV has version 1.0, explicit -Dscope overrides + setVariableValueToObject(mojo, "gav", "com.example:lib:1.0"); + setVariableValueToObject(mojo, "scope", "test"); + + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("1.0"), "version from gav should be used"); + assertTrue(result.contains("test"), "explicit -Dscope should override"); + } + + // --- Convention detection unit tests --- + + @Nested + class DetectPropertyPatternTest { + + @Test + void detectsDotVersionSuffix() { + List versions = Arrays.asList("${guava.version}", "${junit.version}", "${slf4j.version}"); + assertEquals( + AddDependencyMojo.PropertyPattern.DOT_VERSION, AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void detectsDashVersionSuffix() { + List versions = Arrays.asList("${guava-version}", "${junit-version}", "${slf4j-version}"); + assertEquals( + AddDependencyMojo.PropertyPattern.DASH_VERSION, AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void detectsCamelCaseVersionSuffix() { + List versions = Arrays.asList("${guavaVersion}", "${junitVersion}", "${slf4jVersion}"); + assertEquals( + AddDependencyMojo.PropertyPattern.CAMEL_VERSION, AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void detectsVersionDotPrefix() { + List versions = Arrays.asList("${version.guava}", "${version.junit}", "${version.slf4j}"); + assertEquals( + AddDependencyMojo.PropertyPattern.VERSION_PREFIX, + AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void returnsNullForEmptyList() { + assertNull(AddDependencyMojo.detectPropertyPattern(Collections.emptyList())); + } + + @Test + void returnsNullForLiteralVersionsOnly() { + List versions = Arrays.asList("1.0.0", "2.3.4", "5.0"); + assertNull(AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void returnsNullForUnrecognizedPropertyPattern() { + List versions = Arrays.asList("${custom_ver}", "${another_ver}"); + assertNull(AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void majorityWinsWithMixedPatterns() { + List versions = + Arrays.asList("${guava.version}", "${junit.version}", "${slf4j.version}", "${commons-version}"); + assertEquals( + AddDependencyMojo.PropertyPattern.DOT_VERSION, AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void handlesSinglePropertyRef() { + List versions = Collections.singletonList("${guava-version}"); + assertEquals( + AddDependencyMojo.PropertyPattern.DASH_VERSION, AddDependencyMojo.detectPropertyPattern(versions)); + } + + @Test + void ignoresNonPropertyVersionsInMix() { + List versions = Arrays.asList("1.0.0", "${guava.version}", "2.3.4", "${junit.version}"); + assertEquals( + AddDependencyMojo.PropertyPattern.DOT_VERSION, AddDependencyMojo.detectPropertyPattern(versions)); + } + } + + @Nested + class ConventionsTest { + + @Test + void derivePropertyNameWithDotVersionPattern() { + AddDependencyMojo.Conventions conv = new AddDependencyMojo.Conventions(); + conv.pattern = AddDependencyMojo.PropertyPattern.DOT_VERSION; + DependencyEntry coords = new DependencyEntry("com.google.guava", "guava"); + assertEquals("guava.version", conv.derivePropertyName(coords)); + } + + @Test + void derivePropertyNameWithDashVersionPattern() { + AddDependencyMojo.Conventions conv = new AddDependencyMojo.Conventions(); + conv.pattern = AddDependencyMojo.PropertyPattern.DASH_VERSION; + DependencyEntry coords = new DependencyEntry("com.google.guava", "guava"); + assertEquals("guava-version", conv.derivePropertyName(coords)); + } + + @Test + void derivePropertyNameWithCamelVersionPattern() { + AddDependencyMojo.Conventions conv = new AddDependencyMojo.Conventions(); + conv.pattern = AddDependencyMojo.PropertyPattern.CAMEL_VERSION; + DependencyEntry coords = new DependencyEntry("com.google.guava", "guava"); + assertEquals("guavaVersion", conv.derivePropertyName(coords)); + } + + @Test + void derivePropertyNameWithVersionPrefixPattern() { + AddDependencyMojo.Conventions conv = new AddDependencyMojo.Conventions(); + conv.pattern = AddDependencyMojo.PropertyPattern.VERSION_PREFIX; + DependencyEntry coords = new DependencyEntry("com.google.guava", "guava"); + assertEquals("version.guava", conv.derivePropertyName(coords)); + } + + @Test + void derivePropertyNameDefaultsWhenPatternIsNull() { + AddDependencyMojo.Conventions conv = new AddDependencyMojo.Conventions(); + conv.pattern = null; + DependencyEntry coords = new DependencyEntry("com.google.guava", "guava"); + assertEquals("guava.version", conv.derivePropertyName(coords)); + } + + @Test + void defaultConventionsAreAllFalse() { + AddDependencyMojo.Conventions conv = new AddDependencyMojo.Conventions(); + assertFalse(conv.useManaged); + assertFalse(conv.useProperty); + assertNull(conv.pattern); + assertNull(conv.managedPomFile); + } + } + + @Nested + class PropertyPatternTest { + + @Test + void dotVersionMatchesCorrectly() { + assertTrue(AddDependencyMojo.PropertyPattern.DOT_VERSION.matches("guava.version")); + assertFalse(AddDependencyMojo.PropertyPattern.DOT_VERSION.matches("guava-version")); + assertFalse(AddDependencyMojo.PropertyPattern.DOT_VERSION.matches("version.guava")); + } + + @Test + void dashVersionMatchesCorrectly() { + assertTrue(AddDependencyMojo.PropertyPattern.DASH_VERSION.matches("guava-version")); + assertFalse(AddDependencyMojo.PropertyPattern.DASH_VERSION.matches("guava.version")); + } + + @Test + void camelVersionMatchesCorrectly() { + assertTrue(AddDependencyMojo.PropertyPattern.CAMEL_VERSION.matches("guavaVersion")); + assertFalse(AddDependencyMojo.PropertyPattern.CAMEL_VERSION.matches("guava.version")); + } + + @Test + void versionPrefixMatchesCorrectly() { + assertTrue(AddDependencyMojo.PropertyPattern.VERSION_PREFIX.matches("version.guava")); + assertFalse(AddDependencyMojo.PropertyPattern.VERSION_PREFIX.matches("guava.version")); + } + + @Test + void toPropertyNameProducesExpectedResults() { + assertEquals("guava.version", AddDependencyMojo.PropertyPattern.DOT_VERSION.toPropertyName("guava")); + assertEquals("guava-version", AddDependencyMojo.PropertyPattern.DASH_VERSION.toPropertyName("guava")); + assertEquals("guavaVersion", AddDependencyMojo.PropertyPattern.CAMEL_VERSION.toPropertyName("guava")); + assertEquals("version.guava", AddDependencyMojo.PropertyPattern.VERSION_PREFIX.toPropertyName("guava")); + } + } +} diff --git a/src/test/java/org/apache/maven/plugins/dependency/RemoveDependencyMojoTest.java b/src/test/java/org/apache/maven/plugins/dependency/RemoveDependencyMojoTest.java new file mode 100644 index 000000000..999c10f8f --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/dependency/RemoveDependencyMojoTest.java @@ -0,0 +1,375 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.Model; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.sonatype.plexus.build.incremental.BuildContext; + +import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RemoveDependencyMojoTest { + + @TempDir + File tempDir; + + private MavenSession session; + private BuildContext buildContext; + private MavenProject project; + private RemoveDependencyMojo mojo; + + @BeforeEach + void setUp() throws Exception { + session = mock(MavenSession.class); + buildContext = mock(BuildContext.class); + project = mock(MavenProject.class); + when(buildContext.isIncremental()).thenReturn(false); + + mojo = new RemoveDependencyMojo(session, buildContext, project); + } + + private File createTempPom(String content) throws IOException { + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return pomFile; + } + + @Test + void propertyInterpolatedDependencyShowsClearError() throws Exception { + // POM has a dependency with property reference that PomEditor can't match + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " ${some.group}\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + when(project.getModules()).thenReturn(Collections.emptyList()); + + // Original model has the interpolated dependency + Model originalModel = new Model(); + Dependency dep = new Dependency(); + dep.setGroupId("com.example"); + dep.setArtifactId("lib"); + dep.setVersion("1.0"); + originalModel.addDependency(dep); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("property references")); + } + + @Test + void removeNonexistentAndNotInModelGivesNotFoundError() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " existing\n" + + " dep\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + when(project.getModules()).thenReturn(Collections.emptyList()); + + // Original model also doesn't have it + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "nonexistent:lib"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + void typeParameterUsedForMatching() throws Exception { + // POM has both jar and pom variants + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " test-jar\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + when(project.getModules()).thenReturn(Collections.emptyList()); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "type", "test-jar"); + + mojo.execute(); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(!result.contains("test-jar"), "test-jar variant should be removed"); + assertTrue(result.contains("com.example"), "jar variant should remain"); + } + + @Test + void modelSyncPreservesOtherVariantsOnRemove() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " test-jar\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + when(project.getModules()).thenReturn(Collections.emptyList()); + + // Set up in-memory model with both variants + Model model = new Model(); + Dependency jarDep = new Dependency(); + jarDep.setGroupId("com.example"); + jarDep.setArtifactId("lib"); + jarDep.setVersion("1.0"); + Dependency testJarDep = new Dependency(); + testJarDep.setGroupId("com.example"); + testJarDep.setArtifactId("lib"); + testJarDep.setVersion("1.0"); + testJarDep.setType("test-jar"); + model.addDependency(jarDep); + model.addDependency(testJarDep); + when(project.getModel()).thenReturn(model); + + // Remove only the test-jar variant + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "type", "test-jar"); + + mojo.execute(); + + // In-memory model should still have the jar variant + assertTrue(model.getDependencies().size() == 1, "model should have 1 dependency remaining"); + assertTrue( + "jar".equals(model.getDependencies().get(0).getType()) + || model.getDependencies().get(0).getType() == null, + "remaining dependency should be the jar variant"); + } + + @Test + void profileNotFoundThrowsClearError() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + "\n"; + when(project.getFile()).thenReturn(createTempPom(pom)); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "profile", "nonexistent"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("Profile 'nonexistent' not found")); + } + + @Test + void profileNotFoundWhenNoProfilesSectionThrowsClearError() throws Exception { + String pom = + "\n" + "\n" + " \n" + "\n"; + when(project.getFile()).thenReturn(createTempPom(pom)); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "profile", "dev"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("Profile 'dev' not found")); + } + + @Test + void removeDependencyFromProfileSucceeds() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + when(project.getFile()).thenReturn(pomFile); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "profile", "dev"); + + mojo.execute(); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(!result.contains("com.example"), "dependency should be removed"); + assertTrue(result.contains("dev"), "profile should remain"); + } + + @Test + void managedRemovalWithChildModulesWarnsAndProceeds() throws Exception { + // Parent POM with managed dependency + String parentPom = "\n" + + "\n" + + " \n" + + " child-a\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(parentPom); + when(project.getFile()).thenReturn(pomFile); + when(project.getBasedir()).thenReturn(tempDir); + when(project.getModules()).thenReturn(Arrays.asList("child-a")); + + // Child module POM that references the dependency without a version + File childDir = new File(tempDir, "child-a"); + childDir.mkdirs(); + String childPom = "\n" + + "\n" + + " \n" + + " com.example\n" + + " parent\n" + + " 1.0\n" + + " \n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " \n" + + " \n" + + "\n"; + Files.write(new File(childDir, "pom.xml").toPath(), childPom.getBytes(StandardCharsets.UTF_8)); + + setVariableValueToObject(mojo, "gav", "com.example:lib"); + setVariableValueToObject(mojo, "managed", true); + + // Should succeed (warning only, not blocking) — the dependency gets removed + assertDoesNotThrow(() -> mojo.execute()); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(!result.contains("lib"), "managed dep should be removed"); + } + + @Test + void missingGavFails() throws Exception { + when(project.getFile()).thenReturn(createTempPom("")); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("You must specify -Dgav=groupId:artifactId")); + } + + @Test + void malformedGavFails() throws Exception { + when(project.getFile()).thenReturn(createTempPom("")); + + setVariableValueToObject(mojo, "gav", "only-one-part"); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("GAV")); + } + + @Test + void removeFromManagedSectionNotFound() throws Exception { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " other\n" + + " dep\n" + + " 1.0\n" + + " \n" + + " \n" + + " \n" + + "\n"; + when(project.getFile()).thenReturn(createTempPom(pom)); + when(project.getModules()).thenReturn(Collections.emptyList()); + Model originalModel = new Model(); + when(project.getOriginalModel()).thenReturn(originalModel); + + setVariableValueToObject(mojo, "gav", "nonexistent:lib"); + setVariableValueToObject(mojo, "managed", true); + + MojoFailureException ex = assertThrows(MojoFailureException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("not found")); + assertTrue(ex.getMessage().contains(""), "error should mention correct section"); + } +} diff --git a/src/test/java/org/apache/maven/plugins/dependency/pom/DependencyEntryTest.java b/src/test/java/org/apache/maven/plugins/dependency/pom/DependencyEntryTest.java new file mode 100644 index 000000000..73d6d50cc --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/dependency/pom/DependencyEntryTest.java @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency.pom; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DependencyEntryTest { + + @Test + void parseMinimalGav() { + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk"); + assertEquals("com.google.adk", coords.getGroupId()); + assertEquals("google-adk", coords.getArtifactId()); + assertNull(coords.getVersion()); + assertNull(coords.getScope()); + assertNull(coords.getType()); + assertNull(coords.getClassifier()); + } + + @Test + void parseWithVersion() { + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:1.0.0"); + assertEquals("com.google.adk", coords.getGroupId()); + assertEquals("google-adk", coords.getArtifactId()); + assertEquals("1.0.0", coords.getVersion()); + } + + @Test + void parseWithExtensionAndVersion() { + // g:a:ext:v format + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:pom:1.0.0"); + assertEquals("com.google.adk", coords.getGroupId()); + assertEquals("google-adk", coords.getArtifactId()); + assertEquals("1.0.0", coords.getVersion()); + assertEquals("pom", coords.getType()); + } + + @Test + void parseFullForm() { + // g:a:ext:cls:v format + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:jar:sources:1.0.0"); + assertEquals("com.google.adk", coords.getGroupId()); + assertEquals("google-adk", coords.getArtifactId()); + assertEquals("1.0.0", coords.getVersion()); + assertNull(coords.getScope()); + assertEquals("jar", coords.getType()); + assertEquals("sources", coords.getClassifier()); + } + + @Test + void parseEmptyOptionalFields() { + // g:a:ext:v with empty extension + DependencyEntry coords = DependencyEntry.parse("g:a::1.0.0"); + assertEquals("g", coords.getGroupId()); + assertEquals("a", coords.getArtifactId()); + assertEquals("1.0.0", coords.getVersion()); + assertNull(coords.getType()); + } + + @Test + void parseInvalidTooFewTokens() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse("only-one")); + } + + @Test + void parseInvalidTooManyTokens() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse("a:b:c:d:e:f")); + } + + @Test + void parseTrailingColonRejectsEmptyGroupId() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse(":artifactId")); + } + + @Test + void parseTrailingColonRejectsEmptyArtifactId() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse("groupId:")); + } + + @Test + void parseDoubleColonRejectsEmptyFields() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse("::")); + } + + @Test + void parseTrailingColonsAcceptedWhenOptionalFieldsEmpty() { + // "g:a:" has 3 tokens with -1 split limit, 3rd is empty = valid (version empty) + DependencyEntry coords = DependencyEntry.parse("g:a:"); + assertEquals("g", coords.getGroupId()); + assertEquals("a", coords.getArtifactId()); + assertNull(coords.getVersion()); + } + + @Test + void parseNullThrows() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse(null)); + } + + @Test + void parseEmptyThrows() { + assertThrows(IllegalArgumentException.class, () -> DependencyEntry.parse("")); + } + + @Test + void validateSuccess() { + DependencyEntry coords = new DependencyEntry("g", "a"); + coords.validate(); // should not throw + } + + @Test + void validateMissingGroupId() { + DependencyEntry coords = new DependencyEntry(null, "a"); + assertThrows(IllegalArgumentException.class, coords::validate); + } + + @Test + void validateMissingArtifactId() { + DependencyEntry coords = new DependencyEntry("g", null); + assertThrows(IllegalArgumentException.class, coords::validate); + } + + @Test + void toStringWithVersion() { + DependencyEntry coords = DependencyEntry.parse("com.example:my-lib:2.0.0"); + assertEquals("com.example:my-lib:2.0.0", coords.toString()); + } + + @Test + void toStringWithoutVersion() { + DependencyEntry coords = new DependencyEntry("com.example", "my-lib"); + assertEquals("com.example:my-lib", coords.toString()); + } + + @Test + void settersWork() { + DependencyEntry coords = new DependencyEntry(); + coords.setGroupId("g"); + coords.setArtifactId("a"); + coords.setVersion("v"); + coords.setScope("test"); + coords.setType("pom"); + coords.setClassifier("cls"); + coords.setOptional(true); + + assertEquals("g", coords.getGroupId()); + assertEquals("a", coords.getArtifactId()); + assertEquals("v", coords.getVersion()); + assertEquals("test", coords.getScope()); + assertEquals("pom", coords.getType()); + assertEquals("cls", coords.getClassifier()); + assertEquals(true, coords.getOptional()); + } + + @Test + void parseTrimsWhitespace() { + DependencyEntry coords = DependencyEntry.parse(" com.example : my-lib : 1.0.0 "); + assertEquals("com.example", coords.getGroupId()); + assertEquals("my-lib", coords.getArtifactId()); + assertEquals("1.0.0", coords.getVersion()); + } + + @Test + void toStringIncludesScope() { + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + coords.setScope("test"); + assertEquals("com.example:lib:1.0.0 [scope=test]", coords.toString()); + } + + @Test + void toStringIncludesNonJarType() { + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + coords.setType("pom"); + assertEquals("com.example:lib:1.0.0 [type=pom]", coords.toString()); + } + + @Test + void toStringIncludesClassifier() { + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + coords.setClassifier("sources"); + assertEquals("com.example:lib:1.0.0 [classifier=sources]", coords.toString()); + } + + @Test + void toStringIncludesAllDetails() { + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + coords.setType("pom"); + coords.setClassifier("linux"); + coords.setScope("provided"); + assertEquals("com.example:lib:1.0.0 [type=pom, classifier=linux, scope=provided]", coords.toString()); + } + + @Test + void toStringOmitsDefaultJarType() { + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + coords.setType("jar"); + assertEquals("com.example:lib:1.0.0", coords.toString()); + } + + @Test + void validateAcceptsValidScopes() { + String[] validScopes = {"compile", "provided", "runtime", "test", "system", "import"}; + for (String scope : validScopes) { + DependencyEntry coords = new DependencyEntry("g", "a"); + coords.setScope(scope); + coords.validate(); // should not throw + } + } + + @Test + void validateRejectsInvalidScope() { + DependencyEntry coords = new DependencyEntry("g", "a"); + coords.setScope("bananas"); + assertThrows(IllegalArgumentException.class, coords::validate); + } + + @Test + void validateAcceptsNullScope() { + DependencyEntry coords = new DependencyEntry("g", "a"); + coords.validate(); // null scope is fine (defaults to compile) + } + + @Test + void validateAcceptsEmptyScopeForClearing() { + DependencyEntry coords = new DependencyEntry("g", "a"); + coords.setScope(""); + coords.validate(); // empty scope is accepted + } + + @Test + void validateRejectsNoneScope() { + DependencyEntry coords = new DependencyEntry("g", "a"); + coords.setScope("NONE"); + assertThrows(IllegalArgumentException.class, coords::validate); + } +} diff --git a/src/test/java/org/apache/maven/plugins/dependency/pom/PomEditorTest.java b/src/test/java/org/apache/maven/plugins/dependency/pom/PomEditorTest.java new file mode 100644 index 000000000..4cb1081ce --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/dependency/pom/PomEditorTest.java @@ -0,0 +1,968 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.dependency.pom; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +import eu.maveniverse.domtrip.Document; +import eu.maveniverse.domtrip.Element; +import eu.maveniverse.domtrip.maven.Coordinates; +import eu.maveniverse.domtrip.maven.PomEditor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PomEditorTest { + + @TempDir + File tempDir; + + private File createTempPom(String content) throws IOException { + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return pomFile; + } + + private static PomEditor loadPomEditor(File pomFile) throws IOException { + try { + String content = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + String upper = content.toUpperCase(Locale.ROOT); + if (upper.contains(" root element but found <" + rootName + ">"); + } + return editor; + } catch (RuntimeException e) { + throw new IOException("Failed to parse POM file: " + pomFile, e); + } + } + + private static void savePomEditor(PomEditor editor, File pomFile) throws IOException { + Path target = pomFile.toPath(); + File tempFile = File.createTempFile("pom", ".xml.tmp", pomFile.getParentFile()); + boolean success = false; + try { + try (OutputStream os = Files.newOutputStream(tempFile.toPath())) { + editor.document().toXml(os); + } + Files.move(tempFile.toPath(), target, StandardCopyOption.REPLACE_EXISTING); + success = true; + } finally { + if (!success) { + Files.deleteIfExists(tempFile.toPath()); + } + } + } + + private static void addDependency(PomEditor editor, String profileId, DependencyEntry coords, boolean managed) { + PomEditor.Dependencies dependencies = profileId == null + ? editor.dependencies() + : editor.dependencies().forProfile(profileId); + Coordinates coordinates = Coordinates.of( + coords.getGroupId(), + coords.getArtifactId(), + coords.getVersion(), + coords.getClassifier(), + coords.getType()); + if (managed) { + dependencies.updateManagedDependency(true, coordinates); + } else { + dependencies.updateDependency(true, coordinates); + } + Element dependency = findDependency( + editor, + profileId, + coords.getGroupId(), + coords.getArtifactId(), + coords.getType(), + coords.getClassifier(), + managed); + if (dependency != null) { + if (coords.getScope() != null && !coords.getScope().isEmpty()) { + editor.updateOrCreateChildElement(dependency, "scope", coords.getScope()); + } + if (coords.getOptional() != null && coords.getOptional()) { + editor.updateOrCreateChildElement(dependency, "optional", "true"); + } + } + } + + private static boolean removeDependency( + PomEditor editor, + String profileId, + String groupId, + String artifactId, + String type, + String classifier, + boolean managed) { + PomEditor.Dependencies dependencies = profileId == null + ? editor.dependencies() + : editor.dependencies().forProfile(profileId); + Coordinates coordinates = Coordinates.of(groupId, artifactId, null, classifier, type); + return managed ? dependencies.deleteManagedDependency(coordinates) : dependencies.deleteDependency(coordinates); + } + + private static Element findDependency( + PomEditor editor, + String profileId, + String groupId, + String artifactId, + String type, + String classifier, + boolean managed) { + Element dependencies = getDependenciesElement(editor, profileId, managed); + if (dependencies == null) { + return null; + } + Coordinates coordinates = Coordinates.of(groupId, artifactId, null, classifier, type); + return dependencies + .childElements("dependency") + .filter(coordinates.predicateGATC()) + .findFirst() + .orElse(null); + } + + private static Element getDependenciesElement(PomEditor editor, String profileId, boolean managed) { + Element context = profileId == null ? editor.root() : editor.profiles().findProfile(profileId); + if (context == null) { + return null; + } + if (managed) { + Element depMgmt = editor.findChildElement(context, "dependencyManagement"); + return depMgmt != null ? editor.findChildElement(depMgmt, "dependencies") : null; + } + return editor.findChildElement(context, "dependencies"); + } + + private static String childText(Element element, String name) { + return element.childElement(name) + .map(Element::textContent) + .map(String::trim) + .orElse(null); + } + + @Test + void addDependencyToEmptyProject() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:1.0.0"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains(""), "Should contain "); + assertTrue(result.contains("com.google.adk"), "Should contain groupId"); + assertTrue(result.contains("google-adk"), "Should contain artifactId"); + assertTrue(result.contains("1.0.0"), "Should contain version"); + } + + @Test + void addDependencyToExistingDependencies() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:1.0.0"); + coords.setScope("test"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.google.adk"), result); + assertTrue(result.contains("test"), result); + // Original dependency still present + assertTrue(result.contains("junit"), result); + } + + @Test + void addDependencyToManagedSection() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:1.0.0"); + addDependency(editor, null, coords, true); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains(""), "Should contain "); + assertTrue(result.contains("com.google.adk"), result); + } + + @Test + void addDependencyWithoutVersion() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = new DependencyEntry("com.google.adk", "google-adk"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.google.adk"), result); + // Verify the dependency block doesn't contain (project's own may exist) + int depStart = result.indexOf("com.google.adk"); + int depBlockEnd = result.indexOf("", depStart); + String depBlock = result.substring(depStart, depBlockEnd); + assertFalse(depBlock.contains(""), "Should not contain when not specified"); + } + + @Test + void findExistingDependency() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + Element found = findDependency(editor, null, "junit", "junit", null, null, false); + assertNotNull(found, "Should find existing dependency"); + + Element notFound = findDependency(editor, null, "com.example", "nonexistent", null, null, false); + assertNull(notFound, "Should not find nonexistent dependency"); + } + + @Test + void removeDependency() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + " com.google.guava\n" + + " guava\n" + + " 31.0-jre\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + boolean removed = removeDependency(editor, null, "com.google.guava", "guava", null, null, false); + assertTrue(removed, "Should successfully remove dependency"); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertFalse(result.contains("com.google.guava"), "Guava should be removed"); + assertTrue(result.contains("junit"), "JUnit should still be present"); + } + + @Test + void removeNonexistentDependencyReturnsFalse() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + boolean removed = removeDependency(editor, null, "com.nonexistent", "lib", null, null, false); + assertFalse(removed, "Should return false for nonexistent dependency"); + } + + @Test + void preservesXmlComments() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("com.example:new-lib:1.0.0"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains(""), "XML comment should be preserved"); + } + + @Test + void addBomDependency() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("org.springframework.boot:spring-boot-dependencies:3.2.0"); + coords.setScope("import"); + coords.setType("pom"); + addDependency(editor, null, coords, true); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains(""), result); + assertTrue(result.contains("import"), result); + assertTrue(result.contains("pom"), result); + } + + @Test + void addDependencyWithNamespacedPom() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + // Should find existing dependency in namespaced POM + Element found = findDependency(editor, null, "junit", "junit", null, null, false); + assertNotNull(found, "Should find dependency in namespaced POM"); + + // Should add new dependency to namespaced POM + DependencyEntry coords = DependencyEntry.parse("com.google.adk:google-adk:1.0.0"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("com.google.adk"), result); + assertTrue(result.contains("xmlns=\"http://maven.apache.org/POM/4.0.0\""), "Namespace should be preserved"); + } + + @Test + void addOptionalDependency() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("com.example:optional-lib:1.0.0"); + coords.setOptional(true); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("true"), "Should contain true"); + } + + @Test + void pomWithoutXmlDeclaration() throws IOException { + String pom = "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertFalse(result.startsWith("com.example"), result); + } + + @Test + void removeWithPrecedingComment() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13.2\n" + + " \n" + + " \n" + + " \n" + + " com.google.guava\n" + + " guava\n" + + " 31.0-jre\n" + + " \n" + + " \n" + + "\n"; + + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + boolean removed = removeDependency(editor, null, "com.google.guava", "guava", null, null, false); + assertTrue(removed); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertFalse(result.contains("com.google.guava"), "Guava should be removed"); + assertTrue(result.contains("junit"), "JUnit should remain"); + } + + @Test + void preservesBom() throws IOException { + String pom = "\n" + + "\n" + + " 4.0.0\n" + + " com.example\n" + + " test\n" + + " 1.0\n" + + "\n"; + + // Write with BOM + byte[] bomBytes = new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + byte[] contentBytes = pom.getBytes(StandardCharsets.UTF_8); + byte[] withBom = new byte[bomBytes.length + contentBytes.length]; + System.arraycopy(bomBytes, 0, withBom, 0, bomBytes.length); + System.arraycopy(contentBytes, 0, withBom, bomBytes.length, contentBytes.length); + + File pomFile = new File(tempDir, "bom-pom.xml"); + Files.write(pomFile.toPath(), withBom); + + PomEditor editor = loadPomEditor(pomFile); + DependencyEntry coords = DependencyEntry.parse("com.example:lib:1.0.0"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + byte[] result = Files.readAllBytes(pomFile.toPath()); + assertTrue( + result.length >= 3 && result[0] == (byte) 0xEF && result[1] == (byte) 0xBB && result[2] == (byte) 0xBF, + "BOM should be preserved"); + } + + @Test + void findDependencyByTypeAndClassifier() throws IOException { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13\n" + + " test-jar\n" + + " test\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + // Default (no type/classifier) matches the first (jar) entry + Element defaultMatch = findDependency(editor, null, "junit", "junit", null, null, false); + assertNotNull(defaultMatch); + assertNull(childText(defaultMatch, "type")); + + // Explicit test-jar type matches the second entry + Element testJarMatch = findDependency(editor, null, "junit", "junit", "test-jar", null, false); + assertNotNull(testJarMatch); + assertEquals("test-jar", childText(testJarMatch, "type")); + + // Non-existent classifier returns null + Element noMatch = findDependency(editor, null, "junit", "junit", null, "sources", false); + assertNull(noMatch); + } + + @Test + void removeDependencyByTypeAndClassifier() throws IOException { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13\n" + + " \n" + + " \n" + + " junit\n" + + " junit\n" + + " 4.13\n" + + " test-jar\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + // Remove only the test-jar variant + assertTrue(removeDependency(editor, null, "junit", "junit", "test-jar", null, false)); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + // The default jar variant should still be there + assertTrue(result.contains("junit"), result); + assertFalse(result.contains("test-jar"), result); + } + + @Test + void findDependencyWithClassifier() throws IOException { + String pom = "\n" + + "\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " \n" + + " \n" + + " com.example\n" + + " lib\n" + + " 1.0\n" + + " sources\n" + + " \n" + + " \n" + + "\n"; + File pomFile = createTempPom(pom); + PomEditor editor = loadPomEditor(pomFile); + + // Match by classifier + Element sourcesMatch = findDependency(editor, null, "com.example", "lib", null, "sources", false); + assertNotNull(sourcesMatch); + assertEquals("sources", childText(sourcesMatch, "classifier")); + + // Default (no classifier) matches the one without classifier + Element defaultMatch = findDependency(editor, null, "com.example", "lib", null, null, false); + assertNotNull(defaultMatch); + assertNull(childText(defaultMatch, "classifier")); + } + + @Test + void rejectsNonProjectRootElement() throws IOException { + String xml = "\n" + + "\n" + + " /tmp/repo\n" + + "\n"; + File pomFile = createTempPom(xml); + IOException ex = assertThrows(IOException.class, () -> loadPomEditor(pomFile)); + assertTrue(ex.getMessage().contains("")); + } + + @Test + void rejectsDoctypeDeclaration() throws IOException { + String xml = "\n" + + "\n" + + "]>\n" + + "\n" + + " &xxe;\n" + + "\n"; + File pomFile = createTempPom(xml); + assertThrows(IOException.class, () -> loadPomEditor(pomFile)); + } + + @Test + void rejectsDoctypeDeclarationCaseInsensitive() throws IOException { + String xml = "\n" + + "\n" + + "]>\n" + + "\n" + + " &xxe;\n" + + "\n"; + File pomFile = createTempPom(xml); + assertThrows(IOException.class, () -> loadPomEditor(pomFile)); + } + + @Test + void bomPrefixedFilePreservesXmlDeclaration() throws IOException { + String xml = "\n" + + "\n" + + " com.example\n" + + "\n"; + // Write with UTF-8 BOM prefix + byte[] xmlBytes = xml.getBytes(StandardCharsets.UTF_8); + byte[] bom = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + byte[] bomPrefixed = new byte[bom.length + xmlBytes.length]; + System.arraycopy(bom, 0, bomPrefixed, 0, bom.length); + System.arraycopy(xmlBytes, 0, bomPrefixed, bom.length, xmlBytes.length); + + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), bomPrefixed); + + PomEditor editor = loadPomEditor(pomFile); + DependencyEntry coords = new DependencyEntry("junit", "junit"); + coords.setVersion("4.13"); + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + byte[] resultBytes = Files.readAllBytes(pomFile.toPath()); + // BOM should still be present + assertEquals((byte) 0xEF, resultBytes[0]); + assertEquals((byte) 0xBB, resultBytes[1]); + assertEquals((byte) 0xBF, resultBytes[2]); + // XML declaration should be preserved + String result = new String(resultBytes, StandardCharsets.UTF_8); + assertTrue(result.contains("\n" + "\n" + " \n" + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + DependencyEntry coords = new DependencyEntry("com.example", "lib"); + coords.setVersion("1.0"); + coords.setScope(""); // empty = NONE sentinel, should not create element + coords.setType(""); // same + coords.setClassifier(""); // same + addDependency(editor, null, coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("1.0"), "version should be present"); + assertTrue(!result.contains(""), "empty scope should not create element"); + assertTrue(!result.contains(""), "empty type should not create element"); + assertTrue(!result.contains(""), "empty classifier should not create element"); + } + + @Test + void addDependencyToExistingProfileWithNoDeps() throws IOException { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " existing\n" + + " lib\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " test-profile\n" + + " \n" + + " \n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + DependencyEntry coords = new DependencyEntry("com.example", "test-lib"); + coords.setVersion("1.0"); + coords.setScope("test"); + addDependency(editor, "test-profile", coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("test-profile"), "profile id should exist"); + assertTrue(result.contains("com.example"), "dependency should be added"); + assertTrue(result.contains("test"), "scope should be present"); + // Original top-level dependency should still exist + assertTrue(result.contains("existing"), "existing dependency should remain"); + } + + @Test + void addDependencyToExistingProfile() throws IOException { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + " existing\n" + + " lib\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + DependencyEntry coords = new DependencyEntry("com.example", "new-lib"); + coords.setVersion("2.0"); + addDependency(editor, "dev", coords, false); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains("existing"), "existing profile dep should remain"); + assertTrue(result.contains("com.example"), "new dep should be added"); + assertTrue(result.contains("2.0"), "version should be present"); + // Should not create a second profile + assertEquals(1, countOccurrences(result, ""), "should reuse existing profile"); + } + + @Test + void removeDependencyFromProfile() throws IOException { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " com.example\n" + + " top-level\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + " com.example\n" + + " profile-lib\n" + + " 1.0\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + boolean removed = removeDependency(editor, "dev", "com.example", "profile-lib", null, null, false); + savePomEditor(editor, pomFile); + + assertTrue(removed, "should find and remove the profile dependency"); + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(!result.contains("profile-lib"), "profile dep should be removed"); + assertTrue(result.contains("top-level"), "top-level dep should remain"); + } + + @Test + void findDependencyInProfileDoesNotFindTopLevel() throws IOException { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " com.example\n" + + " top-level\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + Element found = findDependency(editor, "dev", "com.example", "top-level", null, null, false); + assertNull(found, "should not find top-level dep when targeting a profile"); + } + + @Test + void findProfileReturnsNullForNonexistent() throws IOException { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + assertNull(editor.profiles().findProfile("nonexistent"), "should return null for non-existent profile"); + assertNotNull(editor.profiles().findProfile("dev"), "should find existing profile"); + } + + @Test + void findProfileReturnsNullWhenNoProfilesSection() throws IOException { + String xml = + "\n" + "\n" + " \n" + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + assertNull(editor.profiles().findProfile("any"), "should return null when no profiles section exists"); + } + + @Test + void addManagedDependencyToProfile() throws IOException { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " dev\n" + + " \n" + + " \n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + DependencyEntry coords = new DependencyEntry("com.example", "lib"); + coords.setVersion("1.0"); + addDependency(editor, "dev", coords, true); + savePomEditor(editor, pomFile); + + String result = new String(Files.readAllBytes(pomFile.toPath()), StandardCharsets.UTF_8); + assertTrue(result.contains(""), "dependencyManagement should be created in profile"); + assertTrue(result.contains("com.example"), "dependency should be added"); + assertTrue(result.contains("dev"), "profile id should remain"); + } + + @Test + void removeDependencyWhenSectionAbsentReturnsFalse() throws IOException { + String xml = "\n" + + "\n" + + " 4.0.0\n" + + "\n"; + File pomFile = new File(tempDir, "pom.xml"); + Files.write(pomFile.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + PomEditor editor = loadPomEditor(pomFile); + assertFalse( + removeDependency(editor, null, "com.example", "lib", null, null, false), + "should return false when no dependencies section exists"); + assertFalse( + removeDependency(editor, null, "com.example", "lib", null, null, true), + "should return false when no dependencyManagement section exists"); + } + + private static int countOccurrences(String text, String substring) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(substring, idx)) != -1) { + count++; + idx += substring.length(); + } + return count; + } +} diff --git a/src/test/resources/unit/add-dependency/pom.xml b/src/test/resources/unit/add-dependency/pom.xml new file mode 100644 index 000000000..4262b1f7d --- /dev/null +++ b/src/test/resources/unit/add-dependency/pom.xml @@ -0,0 +1,48 @@ + + + + + + 4.0.0 + + com.example + test-project + 1.0-SNAPSHOT + + + + + junit + junit + 4.13.2 + test + + + + + + + maven-dependency-plugin + + + + + + diff --git a/src/test/resources/unit/remove-dependency/pom.xml b/src/test/resources/unit/remove-dependency/pom.xml new file mode 100644 index 000000000..adef76fb8 --- /dev/null +++ b/src/test/resources/unit/remove-dependency/pom.xml @@ -0,0 +1,52 @@ + + + + + + 4.0.0 + + com.example + test-project + 1.0-SNAPSHOT + + + + junit + junit + 4.13.2 + test + + + com.google.guava + guava + 31.0-jre + + + + + + + maven-dependency-plugin + + + + + +