ComponentVersion.java

/*
 * Copyright © 2025 Indiana University
 * All rights reserved.
 *
 * BSD 3-Clause License
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * - Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * - Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package iu.type;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Properties;
import java.util.jar.Attributes;
import java.util.jar.Attributes.Name;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import edu.iu.IuException;
import edu.iu.UnsafeFunction;
import edu.iu.type.IuComponentVersion;

/**
 * Implementation of {@link IuComponentVersion}.
 */
class ComponentVersion implements IuComponentVersion {

	private static final Pattern SPEC_VERSION_PATTERN = Pattern.compile("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$");
	private static final Pattern NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9\\-\\.]*$");

	/**
	 * Specification version constant for detecting <a href=
	 * "https://jakarta.ee/specifications/servlet/6.0/jakarta-servlet-spec-6.0">Jakarta
	 * Servlet version 6</a> or higher.
	 */
	static final ComponentVersion SERVLET_6 = new ComponentVersion("jakarta.servlet-api", 6, 0);

	/**
	 * Determines the component version for a path entry: class folder or
	 * {@link JarFile jar file}.
	 * 
	 * @param pathEntry path entry
	 * @return component version
	 * @throws IOException if an I/O error occurs discovering the component version
	 */
	static ComponentVersion of(Path pathEntry) throws IOException {
		final var properties = new Properties();
		final UnsafeFunction<InputStream, ComponentVersion> withPomPropertiesInput = in -> {
			properties.load(in);
			final var name = Objects.requireNonNull(properties.getProperty("artifactId"));
			final var version = Objects.requireNonNull(properties.getProperty("version"));
			return new ComponentVersion(name, version);
		};
		final UnsafeFunction<Path, ComponentVersion> withPomPropertiesPath = pomPropertiesPath -> {
			try (final var in = Files.newInputStream(pomPropertiesPath)) {
				return withPomPropertiesInput.apply(in);
			}
		};

		if (Files.isDirectory(pathEntry)) {
			Path pomPropertiesPath;
			if (Files.isReadable(pomPropertiesPath = pathEntry.resolveSibling("maven-archiver/pom.properties")))
				return IuException.checked(IOException.class, pomPropertiesPath, withPomPropertiesPath);

			final var metaInfMaven = pathEntry.resolve("maven");
			if (Files.isDirectory(metaInfMaven)) {
				final var groupIdIterator = Files.list(metaInfMaven).iterator();
				final Path groupId;
				if (groupIdIterator.hasNext() //
						&& Files.isDirectory(groupId = groupIdIterator.next()) //
						&& !groupIdIterator.hasNext()) {
					final var artifactIdIterator = Files.list(groupId).iterator();
					final Path artifactId;
					if (artifactIdIterator.hasNext() //
							&& Files.isDirectory(artifactId = artifactIdIterator.next()) //
							&& !artifactIdIterator.hasNext() //
							&& Files.isReadable(pomPropertiesPath = artifactId.resolve("pom.properties")))
						return IuException.checked(IOException.class, pomPropertiesPath, withPomPropertiesPath);
				}
			}

			throw new IllegalArgumentException(
					"Missing ../maven-archiver/pom.properties or META-INF/maven/{groupId}/{artifactId}/pom.properties");

		} else
			try (final var in = Files.newInputStream(pathEntry); final var jar = new JarInputStream(in)) {
				JarEntry entry;
				while ((entry = jar.getNextJarEntry()) != null) {
					final var entryName = entry.getName();
					if (entryName.startsWith("META-INF/maven/") && entryName.endsWith("/pom.properties"))
						return IuException.checked(IOException.class, jar, withPomPropertiesInput);
				}

				throw new IllegalArgumentException("Missing META-INF/maven/{groupId}/{artifactId}/pom.properties");
			}
	}

	private final String name;
	private final String version;
	private final int major;
	private final int minor;

	/**
	 * Creates a specification version.
	 * 
	 * @param name  extension name
	 * @param major major version number
	 * @param minor minor version number
	 */
	ComponentVersion(String name, int major, int minor) {
		if (name == null || !NAME_PATTERN.matcher(name).matches())
			throw new IllegalArgumentException(
					"Component name must be non-null, start with a letter, and contain only letters, numbers, dots '.', and hyphens '-'");
		if (major < 0)
			throw new IllegalArgumentException("Component major version number must be non-negative");
		if (minor < 0)
			throw new IllegalArgumentException("Component minor version number must be non-negative");
		this.name = Objects.requireNonNull(name);
		this.version = null;
		this.major = major;
		this.minor = minor;
	}

	/**
	 * Creates an implementation version
	 * 
	 * @param name    extension name
	 * @param version implementation version
	 */
	ComponentVersion(String name, String version) {
		if (name == null || !NAME_PATTERN.matcher(name).matches())
			throw new IllegalArgumentException(
					"Component name must be non-null, start with a letter, and contain only letters, numbers, dots '.', and hyphens '-'");

		if (version == null)
			throw new IllegalArgumentException("Missing version for " + name + ", must be a valid semantic version");

		Matcher semverMatcher;
		if (!(semverMatcher = SEMANTIC_VERSION_PATTERN.matcher(version)).matches()
				&& !(semverMatcher = SPEC_VERSION_PATTERN.matcher(version)).matches())
			throw new IllegalArgumentException("Invalid version for " + name + ", must be a valid semantic version");

		this.name = name;
		this.version = version;
		this.major = Integer.parseInt(semverMatcher.group(1));
		this.minor = Integer.parseInt(semverMatcher.group(2));
	}

	/**
	 * Reads a dependency item from an extension list
	 * 
	 * @param extenstionListItem extension list item
	 * @param mainAttributes     {@link Manifest#getMainAttributes()}
	 */
	ComponentVersion(String extenstionListItem, Attributes mainAttributes) {
		var extensionAttributePrefix = extenstionListItem.replace('.', '_');
		var extensionNameAttribute = extensionAttributePrefix + '-' + Name.EXTENSION_NAME;
		name = mainAttributes.getValue(extensionNameAttribute);
		if (name == null)
			throw new IllegalArgumentException(
					"Missing " + extensionNameAttribute + " in META-INF/MANIFEST.MF main attributes");

		var implementationVersionAttribute = extensionAttributePrefix + '-' + Name.IMPLEMENTATION_VERSION;
		version = mainAttributes.getValue(implementationVersionAttribute);
		if (version != null) {
			Matcher semverMatcher;
			if (!(semverMatcher = SEMANTIC_VERSION_PATTERN.matcher(version)).matches()
					&& !(semverMatcher = SPEC_VERSION_PATTERN.matcher(version)).matches())
				throw new IllegalArgumentException("Invalid version for " + implementationVersionAttribute
						+ " in META-INF/MANIFEST.MF main attributes, must be a valid semantic version");
			major = Integer.parseInt(semverMatcher.group(1));
			minor = Integer.parseInt(semverMatcher.group(2));

		} else {
			var specificationVersionAttribute = extensionAttributePrefix + '-' + Name.SPECIFICATION_VERSION;
			var specificationVersion = mainAttributes.getValue(specificationVersionAttribute);
			if (specificationVersion == null)
				throw new IllegalArgumentException("Missing " + implementationVersionAttribute + " or "
						+ specificationVersionAttribute + " in META-INF/MANIFEST.MF main attributes");

			var specverMatcher = SPEC_VERSION_PATTERN.matcher(specificationVersion);
			if (!specverMatcher.matches())
				throw new IllegalArgumentException("Invalid version for " + specificationVersionAttribute
						+ " in META-INF/MANIFEST.MF main attributes , must be a valid semantic version");
			major = Integer.parseInt(specverMatcher.group(1));
			minor = Integer.parseInt(specverMatcher.group(2));
		}
	}

	@Override
	public String name() {
		return name;
	}

	@Override
	public String implementationVersion() {
		return version;
	}

	@Override
	public int major() {
		return major;
	}

	@Override
	public int minor() {
		return minor;
	}

	@Override
	public ComponentVersion specificationVersion() {
		if (version == null)
			return this;
		else
			return new ComponentVersion(name, major, minor);
	}

	@Override
	public int hashCode() {
		return Objects.hash(major, minor, name, version);
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (!(obj instanceof IuComponentVersion))
			return false;

		IuComponentVersion other = (IuComponentVersion) obj;
		return major == other.major() && minor == other.minor() && Objects.equals(name, other.name())
				&& Objects.equals(version, other.implementationVersion());
	}

	@Override
	public String toString() {
		var sb = new StringBuilder();
		sb.append(name).append('-');
		if (version == null)
			sb.append(major).append('.').append(minor).append('+');
		else
			sb.append(version);
		return sb.toString();
	}

}