Add a parent for static features 62/97862/59
authorDominik Vrbovsky <dominik.vrbovsky@pantheon.tech>
Mon, 11 Oct 2021 07:28:47 +0000 (09:28 +0200)
committerRobert Varga <robert.varga@pantheon.tech>
Tue, 12 Jul 2022 12:27:39 +0000 (14:27 +0200)
Create a maven plugin, template-feature-plugin, which replaces
mustache-enclosed tags in feature.xml with versions from pom.xml.
We process src/main/feature/template.xml and generate a
target/feature/templated-feature.xml.

Also add template-feature-parent, a POM for static features, which
wires templated-feature.xml with karaf-plugin and SFT to form package
and validate the feature.

JIRA: ODLPARENT-235
Change-Id: I2a8e1b4114e9bb2954ede8511bd991b0fca781cd
Signed-off-by: Dominik Vrbovsky <dominik.vrbovsky@pantheon.tech>
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
features/odl-antlr4/pom.xml
features/odl-antlr4/src/main/feature/template.xml [new file with mode: 0644]
features/odl-antlr4/src/main/history/dependencies.xml [deleted file]
pom.xml
template-feature-parent/pom.xml [new file with mode: 0644]
template-feature-plugin/pom.xml [new file with mode: 0644]
template-feature-plugin/src/main/java/org/opendaylight/odlparent/template/feature/plugin/GenerateFeatureMojo.java [new file with mode: 0644]
template-feature-plugin/src/main/resources/META-INF/m2e/lifecycle-mapping-metadata.xml [new file with mode: 0644]
template-feature-plugin/src/test/java/org/opendaylight/odlparent/template/feature/plugin/GenerateFeatureMojoTest.java [new file with mode: 0644]

index 1a048c9327bb3210d7460ec94e895017397ae11e..554f5b5f4acaa4e8836cfb91ec0cbe38dec7a2f5 100644 (file)
 
     <parent>
         <groupId>org.opendaylight.odlparent</groupId>
-        <artifactId>single-feature-parent</artifactId>
+        <artifactId>template-feature-parent</artifactId>
         <version>11.0.1-SNAPSHOT</version>
-        <relativePath>../../single-feature-parent</relativePath>
+        <relativePath>../../template-feature-parent</relativePath>
     </parent>
 
-    <groupId>org.opendaylight.odlparent</groupId>
     <artifactId>odl-antlr4</artifactId>
-    <version>11.0.1-SNAPSHOT</version>
     <packaging>feature</packaging>
 
     <name>OpenDaylight :: ANTLRv4 Runtime</name>
     <description>ANTLR v4</description>
 
-    <properties>
-        <checkDependencyChange>true</checkDependencyChange>
-        <failOnDependencyChange>true</failOnDependencyChange>
-    </properties>
-
     <dependencies>
         <dependency>
             <groupId>org.antlr</groupId>
diff --git a/features/odl-antlr4/src/main/feature/template.xml b/features/odl-antlr4/src/main/feature/template.xml
new file mode 100644 (file)
index 0000000..425eb09
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-antlr4">
+    <feature name="odl-antlr4">
+        <bundle>mvn:org.antlr/antlr4-runtime/{{versionAsInProject}}</bundle>
+    </feature>
+</features>
diff --git a/features/odl-antlr4/src/main/history/dependencies.xml b/features/odl-antlr4/src/main/history/dependencies.xml
deleted file mode 100644 (file)
index 3afd996..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<features xmlns="http://karaf.apache.org/xmlns/features/v1.5.0" name="odl-antlr4">
-    <feature version="0.0.0">
-        <bundle>mvn:org.antlr/antlr4-runtime/4.9.3</bundle>
-    </feature>
-</features>
diff --git a/pom.xml b/pom.xml
index e6aefa92f1a25f56f400ea08b506dd436394484c..dd31c7db7a2fb65d540a19bd893f511696b911a5 100644 (file)
--- a/pom.xml
+++ b/pom.xml
         <module>bundle-parent</module>
         <module>abstract-feature-parent</module>
         <module>single-feature-parent</module>
+        <module>template-feature-parent</module>
         <module>feature-repo-parent</module>
         <module>odlparent</module>
         <module>odlparent-lite</module>
 
+        <!-- Plugin for processing templates for Karaf features -->
+        <module>template-feature-plugin</module>
+
         <!-- File copying plugin -->
         <module>copy-files-plugin</module>
 
diff --git a/template-feature-parent/pom.xml b/template-feature-parent/pom.xml
new file mode 100644 (file)
index 0000000..70e7881
--- /dev/null
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright © 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.opendaylight.odlparent</groupId>
+        <artifactId>abstract-feature-parent</artifactId>
+        <version>11.0.1-SNAPSHOT</version>
+        <relativePath>../abstract-feature-parent</relativePath>
+    </parent>
+
+    <artifactId>template-feature-parent</artifactId>
+    <packaging>pom</packaging>
+    <name>ODL :: odlparent :: ${project.artifactId}</name>
+
+    <build>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.opendaylight.odlparent</groupId>
+                    <artifactId>template-feature-plugin</artifactId>
+                    <version>11.0.1-SNAPSHOT</version>
+                </plugin>
+            </plugins>
+        </pluginManagement>
+
+        <plugins>
+            <plugin>
+                <groupId>org.opendaylight.odlparent</groupId>
+                <artifactId>template-feature-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>generate-feature</id>
+                        <goals>
+                            <goal>generate-feature</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.karaf.tooling</groupId>
+                <artifactId>karaf-maven-plugin</artifactId>
+                <version>${karaf.version}</version>
+                <extensions>true</extensions>
+                <configuration>
+                    <enableGeneration>false</enableGeneration>
+                    <inputFile>${project.build.directory}/feature/templated-feature.xml</inputFile>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/template-feature-plugin/pom.xml b/template-feature-plugin/pom.xml
new file mode 100644 (file)
index 0000000..9210f94
--- /dev/null
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright © 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <artifactId>odlparent</artifactId>
+        <groupId>org.opendaylight.odlparent</groupId>
+        <version>11.0.1-SNAPSHOT</version>
+        <relativePath>../odlparent/pom.xml</relativePath>
+    </parent>
+
+    <artifactId>template-feature-plugin</artifactId>
+    <packaging>maven-plugin</packaging>
+    <name>ODL :: odlparent :: ${project.artifactId}</name>
+
+    <prerequisites>
+        <maven>3.8.3</maven>
+    </prerequisites>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-artifact</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-core</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-model</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-plugin-api</artifactId>
+            <version>3.8.3</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven.plugin-tools</groupId>
+            <artifactId>maven-plugin-annotations</artifactId>
+            <version>3.6.2</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.karaf.features</groupId>
+            <artifactId>org.apache.karaf.features.core</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-plugin-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>mojo-descriptor</id>
+                        <goals>
+                            <goal>descriptor</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/template-feature-plugin/src/main/java/org/opendaylight/odlparent/template/feature/plugin/GenerateFeatureMojo.java b/template-feature-plugin/src/main/java/org/opendaylight/odlparent/template/feature/plugin/GenerateFeatureMojo.java
new file mode 100644 (file)
index 0000000..c39754d
--- /dev/null
@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.template.feature.plugin;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.regex.Pattern;
+import javax.xml.bind.JAXBException;
+import org.apache.felix.utils.version.VersionCleaner;
+import org.apache.karaf.features.internal.model.Dependency;
+import org.apache.karaf.features.internal.model.Feature;
+import org.apache.karaf.features.internal.model.Features;
+import org.apache.karaf.features.internal.model.JaxbUtil;
+import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+import org.eclipse.jdt.annotation.Nullable;
+
+@Mojo(name = "generate-feature", defaultPhase = LifecyclePhase.GENERATE_SOURCES,
+      requiresDependencyResolution = ResolutionScope.RUNTIME, threadSafe = true)
+public class GenerateFeatureMojo extends AbstractMojo {
+    // Common groups
+    private static final String LEAD = "lead";
+
+    // Groups in <bundle> and <repository> lines
+    private static final String GROUP_ID = "groupId";
+    private static final String ARTIFACT_ID = "artifactId";
+    private static final String TYPE = "type";
+    private static final String CLASSIFIER = "classifier";
+
+    // Version specification as they appear in raw features and bundles
+    private static final String VERSION_AS_IN_PROJECT = "{{versionAsInProject}}";
+    private static final String SEM_VER_RANGE = "{{semVerRange}}";
+    private static final String PROJECT_VERSION = "{{projectVersion}}";
+
+    // Version specifications as they appear in features after being scrubbed by Karaf marshaller
+    private static final String VERSION_AS_IN_PROJECT_CLEAN = VersionCleaner.clean(VERSION_AS_IN_PROJECT);
+    private static final String SEM_VER_RANGE_CLEAN = VersionCleaner.clean(SEM_VER_RANGE);
+    private static final String PROJECT_VERSION_CLEAN = VersionCleaner.clean(PROJECT_VERSION);
+
+    // mvn:org.opendaylight.genius/lockmanager-api/{{versionAsInProject}}
+    // mvn:org.opendaylight.odlparent/odl-guava/10.0.0-SNAPSHOT/xml/features
+    private static final Pattern MVNURL_PATTERN = Pattern.compile("^(?<" + LEAD + ">(wrap:)?mvn:)"
+        + "(?<" + GROUP_ID + ">[^/]+)/(?<" + ARTIFACT_ID + ">[^/]+)/\\{\\{versionAsInProject\\}\\}"
+        + "(/(?<" + TYPE + ">[^/]+)(/(?<" + CLASSIFIER + ">[^/]+))?)?$");
+
+    private static final String FEATURES_TYPE = "xml";
+    private static final String FEATURES_CLASSIFIER = "features";
+
+    @Parameter(defaultValue = "${project.basedir}/src/main/feature/template.xml")
+    private File inputFile;
+
+    @Parameter(defaultValue = "${project.build.directory}/feature/templated-feature.xml")
+    private File outputFile;
+
+    @Parameter(required = true, defaultValue = "${project}", readonly = true)
+    private MavenProject mavenProject;
+
+    @Override
+    public void execute() throws MojoExecutionException, MojoFailureException {
+        if (!"feature".equals(mavenProject.getPackaging())) {
+            getLog().info("Project packaging is not 'feature', skipping execution");
+            return;
+        }
+
+        // read, process and write the feature
+        final Features features = readFeature(inputFile.toPath());
+        processFeatures(features);
+        writeFeature(features, outputFile.toPath());
+    }
+
+    // Visible for testing
+    void processFeatures(final Features features) throws MojoFailureException {
+        // Process feature repository references, dancing around encapsulation
+        final var featRepos = features.getRepository();
+        final var newRepos = new ArrayList<String>(featRepos.size());
+        for (var repo : featRepos) {
+            newRepos.add(processReference(repo));
+        }
+        featRepos.clear();
+        featRepos.addAll(newRepos);
+
+        // Process all features in-place
+        for (var feature : features.getFeature()) {
+            processFeature(feature);
+        }
+    }
+
+    private void processFeature(final Feature feature) throws MojoFailureException {
+        final var artifactFeature = feature.getName().equals(mavenProject.getArtifactId());
+
+        // Update feature version if needed
+        if (feature.hasVersion()) {
+            feature.setVersion(processVersion(feature));
+        } else if (artifactFeature) {
+            feature.setVersion(osgiVersion(mavenProject.getVersion()));
+        } else {
+            throw new MojoFailureException("Feature \"" + feature.getName() + "\" does not define a version");
+        }
+
+        // Fill in other details if not provided
+        if (artifactFeature) {
+            if (feature.getDescription() == null) {
+                feature.setDescription(mavenProject.getName());
+            }
+            if (feature.getDetails() == null) {
+                feature.setDetails(mavenProject.getDescription());
+            }
+        }
+
+        // Process feature dependencies, updating versions as needed
+        for (var dependency : feature.getFeature()) {
+            if (dependency.hasVersion()) {
+                dependency.setVersion(processVersion(dependency));
+            }
+        }
+
+        // Process feature bundles, updating versions as needed
+        for (var bundle : feature.getBundle()) {
+            bundle.setLocation(processReference(bundle.getLocation()));
+        }
+    }
+
+    private String processReference(final String repository) throws MojoFailureException {
+        final var matcher = MVNURL_PATTERN.matcher(repository);
+        if (!matcher.matches()) {
+            return repository;
+        }
+
+        final var groupId = matcher.group(GROUP_ID);
+        final var artifactId = matcher.group(ARTIFACT_ID);
+        final var type = matcher.group(TYPE);
+        final var classifier = matcher.group(CLASSIFIER);
+        final var version = dependencyVersion(groupId, artifactId, type, classifier);
+
+        final var sb = new StringBuilder()
+            .append(matcher.group(LEAD)).append(groupId).append('/').append(artifactId).append('/').append(version);
+        if (type != null) {
+            sb.append('/').append(type);
+            if (classifier != null) {
+                sb.append('/').append(classifier);
+            }
+        }
+
+        return sb.toString();
+    }
+
+    private String processVersion(final Dependency dependency) throws MojoFailureException {
+        final var version = dependency.getVersion();
+        return switch (version) {
+            case PROJECT_VERSION -> osgiVersion(mavenProject.getVersion());
+            case SEM_VER_RANGE -> semVerRange(featureVersion(dependency.getName()));
+            case VERSION_AS_IN_PROJECT -> osgiVersion(featureVersion(dependency.getName()));
+            default -> version;
+        };
+    }
+
+    private String processVersion(final Feature feature) throws MojoFailureException {
+        // We really would want a switch expression, but alas that is not to be: the input is processed by unmarshaller
+        // and scrubbed in ways that are not compile-time constants
+        final String version = feature.getVersion();
+        if (PROJECT_VERSION_CLEAN.equals(version)) {
+            return osgiVersion(mavenProject.getVersion());
+        } else if (SEM_VER_RANGE_CLEAN.equals(version)) {
+            return semVerRange(featureVersion(feature.getName()));
+        } else if (VERSION_AS_IN_PROJECT_CLEAN.equals(version)) {
+            return osgiVersion(featureVersion(feature.getName()));
+        } else {
+            return version;
+        }
+    }
+
+    private String dependencyVersion(final String groupId, final String artifactId, final @Nullable String type,
+            final @Nullable String classifier) throws MojoFailureException {
+        for (var dep : mavenProject.getDependencies()) {
+            if (artifactId.equals(dep.getArtifactId()) && groupId.equals(dep.getGroupId())
+                && (type == null || type.equals(dep.getType()))
+                && (classifier == null || classifier.equals(dep.getClassifier()))) {
+                return dep.getVersion();
+            }
+        }
+
+        throw new MojoFailureException("Dependency \"" + groupId + ":" + artifactId + "\" not found");
+    }
+
+    private String featureVersion(final String featureName) throws MojoFailureException {
+        // This feature's version
+        if (featureName.equals(mavenProject.getArtifactId())) {
+            return mavenProject.getVersion();
+        }
+
+        for (var dependency : mavenProject.getDependencies()) {
+            if (featureName.equals(dependency.getArtifactId()) && FEATURES_CLASSIFIER.equals(dependency.getClassifier())
+                && FEATURES_TYPE.equals(dependency.getType())) {
+                return dependency.getVersion();
+            }
+        }
+
+        throw new MojoFailureException("Dependency matching feature \"" + featureName + "\" not found");
+    }
+
+    // Visible for testing
+    static String semVerRange(final String version) {
+        final var semVer = new DefaultArtifactVersion(version);
+        final var major = semVer.getMajorVersion();
+        final var minor = semVer.getMinorVersion();
+        final var patch = semVer.getIncrementalVersion();
+
+        final var sb = new StringBuilder() .append('[').append(major);
+        if (minor != 0 || patch != 0) {
+            sb.append('.').append(minor);
+            if (patch != 0) {
+                sb.append('.').append(patch);
+            }
+        }
+        return sb.append(',').append(major + 1).append(')').toString();
+    }
+
+    private static String osgiVersion(final String version) {
+        // Sufficient for now
+        return version.replace('-', '.');
+    }
+
+    private static Features readFeature(final Path path) throws MojoExecutionException {
+        try (var is = Files.newInputStream(path)) {
+            return readFeature(path.toUri().toString(), is);
+        } catch (IOException e) {
+            throw new MojoExecutionException("Failed to read input " + path, e);
+        }
+    }
+
+    // Visible for testing
+    static Features readFeature(final String uri, final InputStream input) throws IOException {
+        return JaxbUtil.unmarshal(uri, input, true);
+    }
+
+    private static void writeFeature(final Features feature, final Path path) throws MojoExecutionException {
+        final var parent = path.getParent();
+        try {
+            Files.createDirectories(parent);
+        } catch (IOException e) {
+            throw new MojoExecutionException("Failed to create parent directory " + parent, e);
+        }
+
+        try (var os = Files.newOutputStream(path)) {
+            writeFeature(feature, os);
+        } catch (IOException | JAXBException e) {
+            throw new MojoExecutionException("Failed to write output " + path, e);
+        }
+    }
+
+    // Visible for testing
+    static void writeFeature(final Features feature, final OutputStream output) throws JAXBException {
+        JaxbUtil.marshal(feature, output);
+    }
+}
diff --git a/template-feature-plugin/src/main/resources/META-INF/m2e/lifecycle-mapping-metadata.xml b/template-feature-plugin/src/main/resources/META-INF/m2e/lifecycle-mapping-metadata.xml
new file mode 100644 (file)
index 0000000..8f538d2
--- /dev/null
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- vi: set et smarttab sw=4 tabstop=4: -->
+<!--
+ Copyright (c) 2022 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+
+ This program and the accompanying materials are made available under the
+ terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ and is available at http://www.eclipse.org/legal/epl-v10.html
+-->
+<lifecycleMappingMetadata>
+    <pluginExecutions>
+        <pluginExecution>
+            <pluginExecutionFilter>
+                <goals>
+                    <goal>generate-feature</goal>
+                </goals>
+            </pluginExecutionFilter>
+            <action>
+                <execute>
+                    <runOnIncremental>true</runOnIncremental>
+                    <runOnConfiguration>true</runOnConfiguration>
+                </execute>
+            </action>
+        </pluginExecution>
+    </pluginExecutions>
+</lifecycleMappingMetadata>
diff --git a/template-feature-plugin/src/test/java/org/opendaylight/odlparent/template/feature/plugin/GenerateFeatureMojoTest.java b/template-feature-plugin/src/test/java/org/opendaylight/odlparent/template/feature/plugin/GenerateFeatureMojoTest.java
new file mode 100644 (file)
index 0000000..1cc6f57
--- /dev/null
@@ -0,0 +1,232 @@
+/*
+ * Copyright (c) 2022 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.odlparent.template.feature.plugin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import javax.xml.bind.JAXBException;
+import org.apache.maven.model.Dependency;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class GenerateFeatureMojoTest {
+    @InjectMocks
+    private GenerateFeatureMojo mojo;
+    @Mock
+    private MavenProject mavenProject;
+    @Mock
+    private Dependency dependency;
+
+    @Test
+    public void testSemVerRange() {
+        assertEquals("[0,1)", GenerateFeatureMojo.semVerRange("0"));
+        assertEquals("[0.1,1)", GenerateFeatureMojo.semVerRange("0.1"));
+        assertEquals("[0.0.1,1)", GenerateFeatureMojo.semVerRange("0.0.1"));
+        assertEquals("[0.1,1)", GenerateFeatureMojo.semVerRange("0.1.0"));
+        assertEquals("[0.1.1,1)", GenerateFeatureMojo.semVerRange("0.1.1"));
+        assertEquals("[1.2.3,2)", GenerateFeatureMojo.semVerRange("1.2.3"));
+    }
+
+    @Test
+    public void testProcessBundle() throws MojoFailureException {
+        doReturn("org.opendaylight.genius").when(dependency).getGroupId();
+        doReturn("lockmanager-api").when(dependency).getArtifactId();
+        doReturn("1.2.3").when(dependency).getVersion();
+
+        doReturn("odl-yangtools-util").when(mavenProject).getArtifactId();
+        doReturn("8.0.0-SNAPSHOT").when(mavenProject).getVersion();
+        doReturn(List.of(dependency)).when(mavenProject).getDependencies();
+
+        assertProcessFeature(
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="odl-yangtools-util" version="8.0.0.SNAPSHOT">
+                    <bundle>mvn:org.opendaylight.genius/lockmanager-api/1.2.3</bundle>
+                </feature>
+            </features>
+            """,
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="odl-yangtools-util">
+                    <bundle>mvn:org.opendaylight.genius/lockmanager-api/{{versionAsInProject}}</bundle>
+                </feature>
+            </features>
+            """);
+    }
+
+    @Test
+    public void testProcessFeature() throws MojoFailureException {
+        doReturn("odl-apache-commons-net").when(dependency).getArtifactId();
+        doReturn("1.2.3").when(dependency).getVersion();
+        doReturn("xml").when(dependency).getType();
+        doReturn("features").when(dependency).getClassifier();
+
+        doReturn("odl-yangtools-util").when(mavenProject).getArtifactId();
+        doReturn("8.0.0-SNAPSHOT").when(mavenProject).getVersion();
+        doReturn(List.of(dependency)).when(mavenProject).getDependencies();
+
+        assertProcessFeature(
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="odl-yangtools-util" version="8.0.0.SNAPSHOT">
+                    <feature version="[1.2.3,2)">odl-apache-commons-net</feature>
+                </feature>
+            </features>
+            """,
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="odl-yangtools-util">
+                    <feature version="{{semVerRange}}">odl-apache-commons-net</feature>
+                </feature>
+            </features>
+            """);
+        assertProcessFeature(
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="odl-yangtools-util" version="8.0.0.SNAPSHOT">
+                    <feature version="1.2.3">odl-apache-commons-net</feature>
+                </feature>
+            </features>
+            """,
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="odl-yangtools-util">
+                    <feature version="{{versionAsInProject}}">odl-apache-commons-net</feature>
+                </feature>
+            </features>
+            """);
+    }
+
+    @Test
+    public void testProcessFeatureProjectVersion() throws MojoFailureException {
+        doReturn("1.2.3-SNAPSHOT").when(mavenProject).getVersion();
+
+        assertProcessFeature(
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="self" version="1.2.3.SNAPSHOT"/>
+            </features>
+            """,
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <feature name="self" version="{{projectVersion}}"/>
+            </features>
+            """);
+    }
+
+    @Test
+    public void testProcessRepository() throws MojoFailureException {
+        doReturn("example").when(dependency).getGroupId();
+        doReturn("example").when(dependency).getArtifactId();
+        doReturn("10.0.0-SNAPSHOT").when(dependency).getVersion();
+        doReturn("xml").when(dependency).getType();
+        doReturn("features").when(dependency).getClassifier();
+
+        doReturn(List.of(dependency)).when(mavenProject).getDependencies();
+
+        assertProcessFeature(
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <repository>mvn:example/example/10.0.0-SNAPSHOT/xml/features</repository>
+            </features>
+            """,
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <repository>mvn:example/example/{{versionAsInProject}}/xml/features</repository>
+            </features>
+            """);
+    }
+
+    @Test
+    public void testFullTranslation() throws MojoFailureException {
+        doReturn("odl-yangtools-util").when(mavenProject).getArtifactId();
+        doReturn("8.0.0-SNAPSHOT").when(mavenProject).getVersion();
+
+        final var trieMap = mock(Dependency.class);
+        doReturn("tech.pantheon.triemap").when(trieMap).getGroupId();
+        doReturn("pt-triemap").when(trieMap).getArtifactId();
+        doReturn("1.2.0").when(trieMap).getVersion();
+        doReturn("xml").when(trieMap).getType();
+        doReturn("features").when(trieMap).getClassifier();
+
+        final var concepts = mock(Dependency.class);
+        doReturn("org.opendaylight.yangtools").when(concepts).getGroupId();
+        doReturn("concepts").when(concepts).getArtifactId();
+        doReturn("8.0.0-SNAPSHOT").when(concepts).getVersion();
+
+        final var util = mock(Dependency.class);
+        doReturn("org.opendaylight.yangtools").when(util).getGroupId();
+        doReturn("util").when(util).getArtifactId();
+        doReturn("8.0.0-SNAPSHOT").when(util).getVersion();
+
+        doReturn(List.of(trieMap, concepts, util)).when(mavenProject).getDependencies();
+
+        assertProcessFeature(
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <repository>mvn:tech.pantheon.triemap/pt-triemap/1.2.0/xml/features</repository>
+                <feature name="odl-yangtools-util" description="Utilities" version="8.0.0.SNAPSHOT">
+                    <details>YANG Tools common concepts and utilities</details>
+                    <feature version="[1.2,2)">pt-triemap</feature>
+                    <bundle>mvn:org.opendaylight.yangtools/concepts/8.0.0-SNAPSHOT</bundle>
+                    <bundle>mvn:org.opendaylight.yangtools/util/8.0.0-SNAPSHOT</bundle>
+                </feature>
+            </features>
+            """,
+            """
+            <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+            <features xmlns="http://karaf.apache.org/xmlns/features/v1.6.0" name="odl-yangtools-util">
+                <repository>mvn:tech.pantheon.triemap/pt-triemap/{{versionAsInProject}}/xml/features</repository>
+                <feature name="odl-yangtools-util" description="Utilities" version="{{projectVersion}}">
+                    <details>YANG Tools common concepts and utilities</details>
+                    <feature version="{{semVerRange}}">pt-triemap</feature>
+                    <bundle>mvn:org.opendaylight.yangtools/concepts/{{versionAsInProject}}</bundle>
+                    <bundle>mvn:org.opendaylight.yangtools/util/{{versionAsInProject}}</bundle>
+                </feature>
+            </features>
+            """);
+    }
+
+    private void assertProcessFeature(final String expected, final String input) {
+        try {
+            final var features = GenerateFeatureMojo.readFeature(null,
+                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)));
+            mojo.processFeatures(features);
+
+            final var output = new ByteArrayOutputStream();
+            GenerateFeatureMojo.writeFeature(features, output);
+            assertEquals(expected, output.toString(StandardCharsets.UTF_8));
+        } catch (IOException | JAXBException | MojoFailureException e) {
+            throw new AssertionError("Failed to process " + input, e);
+        }
+    }
+}