RawModelReadingTest.java

/*
 * 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.impl.model;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import org.apache.maven.api.Session;
import org.apache.maven.api.services.BuilderProblem.Severity;
import org.apache.maven.api.services.ModelBuilder;
import org.apache.maven.api.services.ModelBuilderException;
import org.apache.maven.api.services.ModelBuilderRequest;
import org.apache.maven.api.services.ModelProblem;
import org.apache.maven.api.services.Sources;
import org.apache.maven.impl.standalone.ApiRunner;
import org.junit.jupiter.api.BeforeEach;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
 * Tests {@link ModelBuilder.ModelBuilderSession#validate}, which reads and validates a POM without
 * building it: it returns the problems instead of throwing on them, and it reads only files.
 */
class RawModelReadingTest {

    private static final String DUPLICATE_DEPENDENCY = """
            <dependencies>
              <dependency>
                <groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version>
              </dependency>
              <dependency>
                <groupId>junit</groupId><artifactId>junit</artifactId><version>4.12</version>
              </dependency>
            </dependencies>""";

    private static final String PARENT_AT_LATEST = """
            <parent>
              <groupId>org.test</groupId><artifactId>par</artifactId><version>LATEST</version>
            </parent>""";

    private Session session;
    private ModelBuilder builder;

    @BeforeEach
    void setUp() {
        session = ApiRunner.createSession();
        builder = session.getService(ModelBuilder.class);
    }

    @Test
    void shouldReturnValidationErrorsRatherThanThrowOnThem(@TempDir Path directory) throws IOException {
        Path pom = writePom(directory, "duplicate", coordinates("duplicate") + DUPLICATE_DEPENDENCY);

        assertTrue(
                messages(readRaw(pom), Severity.ERROR).stream().anyMatch(m -> m.contains("must be unique")),
                "the duplicate dependency should come back as a problem, not as an exception");
    }

    @Test
    void shouldReturnWarningThatComesWithNoError(@TempDir Path directory) throws IOException {
        Path pom = writePom(directory, "warning", PARENT_AT_LATEST + coordinates("warning"));

        List<ModelProblem> problems = readRaw(pom);

        assertTrue(
                messages(problems, Severity.WARNING).stream().anyMatch(m -> m.contains("LATEST")),
                "a warning is worth reporting even though nothing was rejected: " + messages(problems));
        assertEquals(List.of(), messages(problems, Severity.ERROR), "nothing should be rejected here");
    }

    @Test
    void shouldReportSubprojectThatIsDeclaredButNotThere(@TempDir Path directory) throws IOException {
        // Found while mapping the reactor, so it is only seen because the whole project is read.
        Path root = writeReactor(directory, "absent");

        assertTrue(
                messages(readRaw(root), Severity.ERROR).stream().anyMatch(m -> m.contains("absent")),
                "a root POM that names a subproject which is not there is worth reporting");
    }

    @Test
    void shouldInferParentVersionOmittedBySubproject(@TempDir Path directory) throws IOException {
        // Leaving out the parent version is what model 4.1.0 is for. It is taken from the parent's
        // own file model, which means the reactor has to be mapped before the subproject is read.
        Path root = writeReactor(directory, "good");
        Path subproject = writeSubproject(root, "good");

        assertEquals(List.of(), messages(readRaw(subproject)), "a versionless parent is not an error in 4.1.0");
    }

    @Test
    void shouldNotReportSiblingThatCannotBeRead(@TempDir Path directory) throws IOException {
        Path root = writeReactor(directory, "good", "broken");
        Path subproject = writeSubproject(root, "good");
        Files.writeString(Files.createDirectories(root.resolveSibling("broken")).resolve("pom.xml"), "<project><bad");

        assertEquals(
                List.of(),
                messages(readRaw(subproject)),
                "the reactor is mapped to find the parent, not to pass judgement on the siblings");
    }

    @Test
    void shouldThrowWhenTheModelCannotBeReadAtAll(@TempDir Path directory) throws IOException {
        Path pom = directory.resolve("pom.xml");
        Files.writeString(pom, "<project><not even xml");

        assertThrows(ModelBuilderException.class, () -> readRaw(pom));
    }

    @Test
    void shouldRejectValidateWhenAnImplementationDoesNotSupportIt() {
        ModelBuilder.ModelBuilderSession unsupporting = request -> {
            throw new UnsupportedOperationException("build is not under test");
        };

        assertThrows(UnsupportedOperationException.class, () -> unsupporting.validate(null));
    }

    @Test
    void shouldValidateSeveralProjectsOnOneSession(@TempDir Path directory) throws IOException {
        // Two reactors meeting in one session under the same coordinates is the case that would
        // break if the session kept a single source per groupId:artifactId.
        Path first = writeSubproject(writeReactor(directory.resolve("first"), "good"), "good");
        Path second = writeSubproject(writeReactor(directory.resolve("second"), "good"), "good");
        ModelBuilder.ModelBuilderSession shared = builder.newSession();

        assertEquals(List.of(), messages(readRaw(shared, first)), "the first project is clean on its own");
        assertEquals(List.of(), messages(readRaw(shared, second)), "the second project sees the same session");
    }

    private List<ModelProblem> readRaw(Path pom) {
        return readRaw(builder.newSession(), pom);
    }

    private List<ModelProblem> readRaw(ModelBuilder.ModelBuilderSession builderSession, Path pom) {
        return builderSession
                .validate(ModelBuilderRequest.builder()
                        .session(session)
                        .source(Sources.buildSource(pom))
                        .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT)
                        .build())
                .getProblemCollector()
                .problems()
                .toList();
    }

    private static List<String> messages(List<ModelProblem> problems) {
        return problems.stream().map(ModelProblem::getMessage).toList();
    }

    private static List<String> messages(List<ModelProblem> problems, Severity severity) {
        return problems.stream()
                .filter(problem -> problem.getSeverity() == severity)
                .map(ModelProblem::getMessage)
                .toList();
    }

    private static String coordinates(String artifactId) {
        return "<groupId>org.test</groupId><artifactId>" + artifactId + "</artifactId><version>1.0</version>";
    }

    private static String project(String modelVersion, String body) {
        return """
                <project xmlns="http://maven.apache.org/POM/%s">
                  <modelVersion>%s</modelVersion>
                  %s
                </project>
                """.formatted(modelVersion, modelVersion, body);
    }

    private static Path writePom(Path directory, String name, String body) throws IOException {
        Path pom = Files.createDirectories(directory.resolve(name)).resolve("pom.xml");
        Files.writeString(pom, project(ModelBuilder.MODEL_VERSION_4_0_0, body));
        return pom;
    }

    /** Writes a 4.1.0 root POM, with the {@code .mvn} directory that makes its directory the root. */
    private static Path writeReactor(Path directory, String... subprojects) throws IOException {
        Path root = Files.createDirectories(directory.resolve("reactor"));
        Files.createDirectories(root.resolve(".mvn"));
        String declared = Arrays.stream(subprojects)
                .map(name -> "<subproject>" + name + "</subproject>")
                .collect(Collectors.joining());
        Files.writeString(
                root.resolve("pom.xml"),
                project(
                        ModelBuilder.MODEL_VERSION_4_1_0,
                        coordinates("reactor") + "<packaging>pom</packaging><subprojects>" + declared
                                + "</subprojects>"));
        return root.resolve("pom.xml");
    }

    private static Path writeSubproject(Path root, String name) throws IOException {
        Path pom = Files.createDirectories(root.resolveSibling(name)).resolve("pom.xml");
        Files.writeString(
                pom,
                project(
                        ModelBuilder.MODEL_VERSION_4_1_0,
                        "<parent><groupId>org.test</groupId><artifactId>reactor</artifactId></parent>" + "<artifactId>"
                                + name + "</artifactId>"));
        return pom;
    }
}