ValidateInvokerTest.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.cling.invoker.mvnval;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.maven.api.ProtoSession;
import org.apache.maven.api.Session;
import org.apache.maven.api.cli.InvokerException;
import org.apache.maven.api.cli.mvnval.ValidateOptions;
import org.apache.maven.api.services.ModelBuilder;
import org.apache.maven.cling.invoker.ProtoLookup;
import org.apache.maven.impl.standalone.ApiRunner;
import org.jline.terminal.Terminal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * Unit tests for the {@link ValidateInvoker} class.
 * Tests that the verdict depends on the file alone, that a file which cannot be read does not
 * lose the verdict on the others, and that the exit code follows the reports.
 */
@DisplayName("ValidateInvoker")
class ValidateInvokerTest {

    private static final String DUPLICATE_DEPENDENCY = """
            <project xmlns="http://maven.apache.org/POM/4.0.0">
              <modelVersion>4.0.0</modelVersion>
              %s
              <groupId>org.test</groupId>
              <artifactId>test</artifactId>
              <version>1.0</version>
              <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>
            </project>
            """;

    private static final String UNRESOLVABLE_PARENT = """
            <parent>
                <groupId>org.nowhere</groupId>
                <artifactId>does-not-exist</artifactId>
                <version>999.0.0</version>
              </parent>""";

    @TempDir
    Path tempDir;

    private List<String> output;

    /** Every path handed to the seam, so a test can assert on the one the run actually made. */
    private List<Path> aimed;

    private ValidateInvoker invoker;

    @BeforeEach
    void setUp() {
        output = new ArrayList<>();
        // One session for the whole test: building one is the expensive part of every test here.
        Session shared = ApiRunner.createSession();
        aimed = new ArrayList<>();
        invoker = new ValidateInvoker(ProtoLookup.builder().build(), null) {
            @Override
            protected Session createSession(Path localRepository) {
                // withLocalRepository is the line production uses, so an aimed repository is
                // exercised rather than stubbed. The transports and the settings-derived
                // repository list production adds are not: this session has neither.
                aimed.add(localRepository);
                return localRepository == null
                        ? shared
                        : shared.withLocalRepository(shared.createLocalRepository(localRepository));
            }
        };
    }

    private Path writePom(String name, String parent) throws Exception {
        Path pom = Files.createDirectories(tempDir.resolve(name)).resolve("pom.xml");
        Files.writeString(pom, String.format(DUPLICATE_DEPENDENCY, parent));
        return pom;
    }

    private int run(List<String> poms) throws Exception {
        return run(poms, Optional.empty());
    }

    private int run(List<String> poms, Optional<String> format) throws Exception {
        return run(poms, format, Optional.of("raw"));
    }

    /** Defaults to raw: the tests using this helper read files only. */
    private int run(List<String> poms, Optional<String> format, Optional<String> mode) throws Exception {
        ValidateOptions options = mock(ValidateOptions.class);
        when(options.format()).thenReturn(format);
        when(options.mode()).thenReturn(mode);
        when(options.poms()).thenReturn(poms.isEmpty() ? Optional.empty() : Optional.of(poms));

        ValidateContext context = TestUtils.createMockContext(tempDir, options);
        context.writer = output::add;
        return invoker.execute(context);
    }

    @Nested
    @DisplayName("Wiring")
    class WiringTests {

        @Test
        @DisplayName("should register the transports resolution needs")
        void shouldRegisterTransports() throws Exception {
            // The block whose absence produces "No transporter factories registered". Proved over
            // file://, which needs FileTransporterFactory and no network. Every other test here
            // replaces createSession, so without this the block could be deleted and stay green.
            Path repository = Files.createDirectories(tempDir.resolve("served/org/test/far/1.0"));
            String parent = """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <groupId>org.test</groupId><artifactId>far</artifactId>
                      <version>1.0</version><packaging>pom</packaging>
                    </project>""";
            Files.writeString(repository.resolve("far-1.0.pom"), parent);
            // A repository without checksums is refused before the content is looked at, so the
            // transport would never be exercised.
            Files.writeString(repository.resolve("far-1.0.pom.sha1"), sha1(parent));
            Path pom = Files.createDirectories(tempDir.resolve("fetcher")).resolve("pom.xml");
            Files.writeString(pom, """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <parent>
                        <groupId>org.test</groupId><artifactId>far</artifactId>
                        <version>1.0</version><relativePath/>
                      </parent>
                      <artifactId>fetcher</artifactId>
                      <repositories>
                        <repository><id>served</id><url>%s</url></repository>
                      </repositories>
                    </project>""".formatted(tempDir.resolve("served").toUri()));

            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.localRepository())
                    .thenReturn(Optional.of(tempDir.resolve("into").toString()));
            when(options.poms()).thenReturn(Optional.of(List.of(pom.toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            int exitCode = new ValidateInvoker(ProtoLookup.builder().build(), null).execute(context);

            assertEquals(ValidateInvoker.OK, exitCode, output.toString());
            assertTrue(
                    Files.exists(tempDir.resolve("into/org/test/far/1.0/far-1.0.pom")),
                    "the parent has to arrive through a transport: " + output);
        }

        @Test
        @DisplayName("should exit on bad usage when the model builder cannot validate")
        void shouldRefuseAModelBuilderThatCannotValidate() throws Exception {
            // validate() is a default that throws, so an out-of-tree ModelBuilder may not have it.
            // Unreachable from the shipped CLI, which is why it is worth pinning here: nothing was
            // wrong with the POM, so this must not be the code that means a POM was rejected.
            ValidateInvoker refusing = new ValidateInvoker(ProtoLookup.builder().build(), null) {
                @Override
                protected Session createSession(Path localRepository) {
                    Session session = mock(Session.class);
                    ModelBuilder builder = mock(ModelBuilder.class);
                    ModelBuilder.ModelBuilderSession builderSession = mock(ModelBuilder.ModelBuilderSession.class);
                    when(session.getService(ModelBuilder.class)).thenReturn(builder);
                    when(builder.newSession()).thenReturn(builderSession);
                    when(builderSession.validate(any()))
                            .thenThrow(new UnsupportedOperationException("does not support validating a model"));
                    return session;
                }
            };
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("raw"));
            when(options.poms())
                    .thenReturn(Optional.of(List.of(writePom("unsupported", "").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            assertEquals(ValidateInvoker.BAD_OPERATION, refusing.execute(context));
        }
    }

    private static String sha1(String content) throws Exception {
        byte[] digest = java.security.MessageDigest.getInstance("SHA-1").digest(content.getBytes(UTF_8));
        StringBuilder hex = new StringBuilder(digest.length * 2);
        for (byte b : digest) {
            hex.append(String.format("%02x", b));
        }
        return hex.toString();
    }

    @ParameterizedTest
    @ValueSource(strings = {"raw", "effective"})
    void shouldLetUserPropertiesOverridePomProperties(String mode) throws Exception {
        Path root = Files.createDirectories(tempDir.resolve("properties"));
        Files.writeString(root.resolve("pom.xml"), """
                <project xmlns="http://maven.apache.org/POM/4.0.0">
                  <modelVersion>4.0.0</modelVersion>
                  <groupId>org.test</groupId><artifactId>parent</artifactId>
                  <version>1.0</version><packaging>pom</packaging>
                  <properties>
                    <child>missing</child>
                    <dependencyVersion/>
                  </properties>
                  <modules><module>${child}</module></modules>
                  <dependencies>
                    <dependency>
                      <groupId>org.test</groupId><artifactId>dependency</artifactId>
                      <version>${dependencyVersion}</version>
                    </dependency>
                  </dependencies>
                </project>
                """);
        Path module = Files.createDirectories(root.resolve("mod"));
        Files.writeString(module.resolve("pom.xml"), """
                <project xmlns="http://maven.apache.org/POM/4.0.0">
                  <modelVersion>4.0.0</modelVersion>
                  <groupId>org.test</groupId><artifactId>mod</artifactId><version>1.0</version>
                </project>
                """);
        ValidateOptions options = mock(ValidateOptions.class);
        when(options.mode()).thenReturn(Optional.of(mode));
        ValidateContext context = TestUtils.createMockContext(root, options);
        context.protoSession = ProtoSession.newBuilder()
                .withUserProperties(Map.of("child", "mod", "dependencyVersion", "1.0"))
                .withTopDirectory(root)
                .build();
        context.writer = output::add;

        assertEquals(ValidateInvoker.OK, invoker.execute(context), output.toString());
        assertEquals(1, output.size());
        assertTrue(output.get(0).endsWith(": no problems"), output.toString());
    }

    @Test
    void shouldCancelFromAnotherThreadAfterTheModelBuilderClearsTheInterrupt() throws Exception {
        ValidateOptions options = mock(ValidateOptions.class);
        when(options.mode()).thenReturn(Optional.of("raw"));
        when(options.poms())
                .thenReturn(Optional.of(List.of(
                        writePom("first", "").toString(), writePom("second", "").toString())));
        ValidateContext context = TestUtils.createMockContext(tempDir, options);
        context.writer = output::add;
        Terminal.SignalHandler previousHandler = mock(Terminal.SignalHandler.class);
        AtomicReference<Terminal.SignalHandler> handler = new AtomicReference<>();
        doAnswer(call -> {
                    handler.set(call.getArgument(1));
                    return previousHandler;
                })
                .when(context.terminal)
                .handle(eq(Terminal.Signal.INT), any());

        Session session = mock(Session.class);
        ModelBuilder builder = mock(ModelBuilder.class);
        ModelBuilder.ModelBuilderSession builderSession = mock(ModelBuilder.ModelBuilderSession.class);
        when(session.getService(ModelBuilder.class)).thenReturn(builder);
        when(builder.newSession()).thenReturn(builderSession);
        AtomicBoolean validationThreadInterrupted = new AtomicBoolean();
        when(builderSession.validate(any())).thenAnswer(call -> {
            CompletableFuture.runAsync(() -> handler.get().handle(Terminal.Signal.INT))
                    .join();
            validationThreadInterrupted.set(Thread.interrupted());
            throw new IllegalStateException("Interrupted model read");
        });
        ValidateInvoker canceling = new ValidateInvoker(ProtoLookup.builder().build(), null) {
            @Override
            protected Session createSession(Path localRepository) {
                return session;
            }
        };

        try {
            assertEquals(ValidateInvoker.CANCELED, canceling.execute(context));
            assertTrue(validationThreadInterrupted.get());
            assertFalse(Thread.currentThread().isInterrupted());
            assertTrue(output.isEmpty(), output.toString());
            verify(builderSession, times(1)).validate(any());
            verify(context.terminal).handle(Terminal.Signal.INT, previousHandler);
        } finally {
            Thread.interrupted();
        }
    }

    @Nested
    @DisplayName("Determinism")
    class DeterminismTests {

        @Test
        @DisplayName("should reach the same verdict whether or not the parent can be resolved")
        void shouldReachSameVerdictRegardlessOfParent() throws Exception {
            Path withParent = writePom("with-parent", UNRESOLVABLE_PARENT);
            Path withoutParent = writePom("without-parent", "");

            int exitWith = run(List.of(withParent.toString()));
            List<String> linesWith = List.copyOf(output);
            output.clear();
            int exitWithout = run(List.of(withoutParent.toString()));

            assertEquals(exitWith, exitWithout, "an unresolvable parent must not change the exit code");
            assertTrue(
                    linesWith.stream().anyMatch(l -> l.contains("must be unique")),
                    "the duplicate dependency should be reported even with an unresolvable parent: " + linesWith);
            assertTrue(
                    output.stream().anyMatch(l -> l.contains("must be unique")),
                    "and also without a parent: " + output);
        }

        @Test
        @DisplayName("should print a path under the working directory as the caller typed it")
        void shouldPrintPathsRelativeToTheWorkingDirectory() throws Exception {
            Path pom = writePom("near", "");
            Path outsideRoot = Files.createTempDirectory("mvnval-outside-");
            Path outsideDirectory = outsideRoot.resolve("far");
            Path outside = outsideDirectory.resolve("pom.xml");
            try {
                Files.createDirectories(outsideDirectory);
                Files.writeString(outside, String.format(DUPLICATE_DEPENDENCY, ""));

                String far = outside.toRealPath().toString();
                run(List.of(pom.toString(), outside.toString()));

                assertTrue(
                        output.stream().anyMatch(line -> line.startsWith(Path.of("near", "pom.xml") + ":")),
                        "a file under the working directory is named relative to it: " + output);
                assertTrue(
                        output.stream().anyMatch(line -> line.startsWith(far)),
                        "a file outside it keeps its absolute path, or it would be ambiguous: " + output);
            } finally {
                Files.deleteIfExists(outside);
                Files.deleteIfExists(outsideDirectory);
                Files.deleteIfExists(outsideRoot);
            }
        }

        @Test
        @DisplayName("should read a POM named twice only once")
        void shouldReadAPomNamedTwiceOnce() throws Exception {
            Path pom = writePom("twice", "");
            List<String> named = new ArrayList<>(List.of(pom.toString(), "./twice/pom.xml"));
            try {
                // Windows refuses this without developer mode, and the two spellings above
                // already make the point there.
                named.add(Files.createSymbolicLink(tempDir.resolve("linked.xml"), pom)
                        .toString());
            } catch (IOException | UnsupportedOperationException e) {
                // no link on this machine
            }

            int exitCode = run(named);

            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
            assertEquals(
                    1,
                    output.stream().filter(line -> line.endsWith(":")).count(),
                    "one file named several ways is one report: " + output);
        }
    }

    @Nested
    @DisplayName("Reporting")
    class ReportingTests {

        @Test
        @DisplayName("should not invent a missing parent version for a 4.1.0 subproject")
        void shouldNotInventMissingParentVersionForSubproject() throws Exception {
            Path root = Files.createDirectories(tempDir.resolve("reactor"));
            Files.createDirectories(root.resolve(".mvn"));
            Files.writeString(root.resolve("pom.xml"), """
                    <project xmlns="http://maven.apache.org/POM/4.1.0">
                      <modelVersion>4.1.0</modelVersion>
                      <groupId>org.test</groupId>
                      <artifactId>parent</artifactId>
                      <version>1.0</version>
                      <packaging>pom</packaging>
                      <subprojects>
                        <subproject>child</subproject>
                      </subprojects>
                    </project>
                    """);
            Path child = Files.createDirectories(root.resolve("child"));
            Files.writeString(child.resolve("pom.xml"), """
                    <project xmlns="http://maven.apache.org/POM/4.1.0">
                      <modelVersion>4.1.0</modelVersion>
                      <parent>
                        <groupId>org.test</groupId>
                        <artifactId>parent</artifactId>
                      </parent>
                      <artifactId>child</artifactId>
                    </project>
                    """);

            int exitCode = run(List.of(child.resolve("pom.xml").toString()));

            assertEquals(
                    ValidateInvoker.OK,
                    exitCode,
                    "leaving out the parent version is what model 4.1.0 is for, not an error: " + output);
        }

        @Test
        @DisplayName("should not blame a POM for a sibling module that cannot be read")
        void shouldNotBlameSiblingModule() throws Exception {
            Path root = Files.createDirectories(tempDir.resolve("siblings"));
            Files.createDirectories(root.resolve(".mvn"));
            Files.writeString(root.resolve("pom.xml"), """
                    <project xmlns="http://maven.apache.org/POM/4.1.0">
                      <modelVersion>4.1.0</modelVersion>
                      <groupId>org.test</groupId>
                      <artifactId>parent</artifactId>
                      <version>1.0</version>
                      <packaging>pom</packaging>
                      <subprojects>
                        <subproject>good</subproject>
                        <subproject>broken</subproject>
                      </subprojects>
                    </project>
                    """);
            Path good = Files.createDirectories(root.resolve("good"));
            Files.writeString(good.resolve("pom.xml"), """
                    <project xmlns="http://maven.apache.org/POM/4.1.0">
                      <modelVersion>4.1.0</modelVersion>
                      <parent><groupId>org.test</groupId><artifactId>parent</artifactId></parent>
                      <artifactId>good</artifactId>
                    </project>
                    """);
            Path broken = Files.createDirectories(root.resolve("broken"));
            Files.writeString(broken.resolve("pom.xml"), "<project><not even xml");

            int exitCode = run(List.of(good.resolve("pom.xml").toString()));

            assertEquals(
                    ValidateInvoker.OK,
                    exitCode,
                    "the reactor is mapped to infer the parent version, not to judge the siblings: " + output);
            assertTrue(
                    output.stream().noneMatch(l -> l.contains("broken")),
                    "the broken sibling must not be reported against this file: " + output);
        }

        @Test
        @DisplayName("should report a file that does not exist without losing the others")
        void shouldReportMissingFileWithoutLosingOthers() throws Exception {
            Path good = writePom("good", "");

            int exitCode = run(List.of(tempDir.resolve("nope/pom.xml").toString(), good.toString()));

            assertEquals(ValidateInvoker.ERROR, exitCode);
            assertTrue(
                    output.stream().anyMatch(l -> l.contains("does not exist")),
                    "the missing file should be reported: " + output);
            assertTrue(
                    output.stream().anyMatch(l -> l.contains("must be unique")),
                    "the other file should still be validated: " + output);
        }

        @Test
        @DisplayName("should report a warning that comes with no error, and exit with the warning code")
        void shouldReportWarningWithoutError() throws Exception {
            Path pom = Files.createDirectories(tempDir.resolve("warn-only")).resolve("pom.xml");
            Files.writeString(pom, """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <parent>
                        <groupId>org.test</groupId><artifactId>par</artifactId><version>LATEST</version>
                      </parent>
                      <artifactId>child</artifactId>
                    </project>
                    """);

            int exitCode = run(List.of(pom.toString()));

            assertEquals(
                    ValidateInvoker.WARNINGS,
                    exitCode,
                    "warnings without errors are what a gate passes back to maintainers: " + output);
            assertTrue(
                    output.stream().anyMatch(l -> l.contains("WARNING")),
                    "the warning must be reported, not swallowed: " + output);
        }

        @Test
        @DisplayName("should exit with an error when a problem was reported")
        void shouldExitWithErrorWhenProblemReported() throws Exception {
            assertEquals(ValidateInvoker.ERROR, run(List.of(writePom("bad", "").toString())));
        }
    }

    @Nested
    @DisplayName("Extensions")
    class ExtensionTests {

        @Test
        @DisplayName("should stand up no container, so the POM's directory cannot make it load code")
        void shouldStandUpNoContainer() throws Exception {
            // Booting it would resolve and run whatever .mvn/extensions.xml declares, and whatever
            // maven.ext.class.path names in .mvn/maven-user.properties.
            ValidateContext context = TestUtils.createMockContext(tempDir, mock(ValidateOptions.class));

            invoker.container(context);
            invoker.postContainer(context);
            invoker.lookup(context);
            invoker.init(context);
            invoker.settings(context);

            assertNull(context.containerCapsule, "no container");
            assertNull(context.lookup, "nothing to look up from");
            assertNull(context.eventSpyDispatcher, "no event spies");
            assertNull(context.effectiveSettings, "no settings, so no mirrors, proxies or credentials");
        }
    }

    @Nested
    @DisplayName("Format")
    class FormatTests {

        @Test
        @DisplayName("should emit one JSON document when json is asked for")
        void shouldEmitJsonDocument() throws Exception {
            Path pom = writePom("as-json", "");

            int exitCode = run(List.of(pom.toString()), Optional.of("json"));

            assertEquals(ValidateInvoker.ERROR, exitCode);
            assertEquals(1, output.size(), "the whole report is one document, not a line per problem: " + output);
            assertTrue(output.get(0).startsWith("[{"), "expected a JSON array: " + output);
            assertTrue(output.get(0).contains("\"severity\":\"ERROR\""), output.get(0));
        }

        @Test
        @DisplayName("should turn an unparseable command line into the bad-usage code")
        void shouldTurnParseFailureIntoBadOperation() {
            ValidateContext context = TestUtils.createMockContext(tempDir, mock(ValidateOptions.class));
            when(context.invokerRequest.parsingFailed()).thenReturn(true);

            InvokerException.ExitException e =
                    assertThrows(InvokerException.ExitException.class, () -> invoker.validate(context));

            assertEquals(
                    ValidateInvoker.BAD_OPERATION,
                    e.getExitCode(),
                    "the base exits 1, which this tool uses for a rejected POM");
        }

        @Test
        @DisplayName("should reject an unknown format with the bad-usage code, not the parse-failure one")
        void shouldRejectUnknownFormatWithBadOperation() throws Exception {
            // Rejecting this while parsing would mark the invocation unparseable and exit 1,
            // which is not the code the tool documents for bad usage.
            int exitCode = run(List.of(writePom("xml-format", "").toString()), Optional.of("xml"));

            assertEquals(ValidateInvoker.BAD_OPERATION, exitCode);
        }

        @Test
        @DisplayName("should reject a format that only became unknown after interpolation")
        void shouldRejectFormatUnknownAfterInterpolation() throws Exception {
            // The parser checks --format, but options are interpolated afterwards, so a value the
            // parser accepted can still be anything by the time the invoker reads it.
            int exitCode = run(List.of(writePom("bad-format", "").toString()), Optional.of("yaml"));

            assertEquals(ValidateInvoker.BAD_OPERATION, exitCode, "a bad format is a usage error, not a verdict");
        }

        @Test
        @DisplayName("should reject an unknown mode with the bad-usage code")
        void shouldRejectUnknownModeWithBadOperation() throws Exception {
            int exitCode = run(List.of(writePom("bad-mode", "").toString()), Optional.empty(), Optional.of("deep"));

            assertEquals(ValidateInvoker.BAD_OPERATION, exitCode);
        }
    }

    @Nested
    @DisplayName("Effective mode")
    class EffectiveTests {

        private Path project(String parent, String relativePath) throws Exception {
            Path root = Files.createDirectories(tempDir.resolve("proj"));
            Files.writeString(root.resolve("pom.xml"), """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <groupId>org.test</groupId><artifactId>par</artifactId>
                      <version>1.0</version><packaging>pom</packaging>
                    </project>""");
            Path child = Files.createDirectories(root.resolve("child")).resolve("pom.xml");
            Files.writeString(child, """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <parent>
                        <groupId>org.test</groupId><artifactId>%s</artifactId>
                        <version>1.0</version>%s
                      </parent>
                      <artifactId>child</artifactId>
                      <dependencies>
                        <dependency><groupId>junit</groupId><artifactId>junit</artifactId></dependency>
                      </dependencies>
                    </project>""".formatted(parent, relativePath));
            return child;
        }

        private int runEffective(Path pom) throws Exception {
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.poms()).thenReturn(Optional.of(List.of(pom.toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;
            return invoker.execute(context);
        }

        @Test
        @DisplayName("should report a problem that only the effective model shows")
        void shouldReportInheritedProblem() throws Exception {
            // A dependency with no version of its own is checked in validateEffectiveModel, which
            // raw mode never reaches. The parent is next door, so nothing has to be resolved.
            int exitCode = runEffective(project("par", ""));

            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
            assertTrue(
                    output.stream().anyMatch(line -> line.contains("'dependencies.dependency.version'")),
                    "the inherited missing version is what effective mode is for: " + output);
        }

        @Test
        @DisplayName("should go looking for a parent that is not on disk")
        void shouldResolveParentThatIsNotOnDisk() throws Exception {
            // Names a parent no file provides, so raw mode is clean and effective mode has to
            // reach for it. The shared session registers no transport, so the reach fails wherever
            // it is run, and the failure is the evidence.
            int exitCode = runEffective(project("absent", "<relativePath/>"));

            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
            assertTrue(
                    output.stream().anyMatch(line -> line.contains("Non-resolvable parent POM")),
                    "effective mode has to reach for a parent it cannot read from disk: " + output);
        }

        @Test
        @DisplayName("should honour --offline and fail when a parent is not in the local repository")
        void shouldHonourOfflineInEffectiveMode() throws Exception {
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.offline()).thenReturn(Optional.of(true));
            when(options.tempLocalRepository()).thenReturn(Optional.of(true));
            when(options.poms())
                    .thenReturn(Optional.of(
                            List.of(project("absent", "<relativePath/>").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            // -o is now honoured: the run fails because the parent cannot be fetched offline,
            // but the failure is a validation result (exit 1), not a bad-usage error (exit 2).
            int exitCode = invoker.execute(context);
            assertNotEquals(
                    ValidateInvoker.BAD_OPERATION,
                    exitCode,
                    "--offline must be honoured, not refused as bad usage: " + output);
        }

        @Test
        @DisplayName("should see a subproject that is not on disk, as the build does")
        void shouldReportMissingSubproject() throws Exception {
            // The regression this mode was changed for. build() reports nothing about the reactor
            // around a POM, so on its own it called this project clean while mvn refuses to read
            // it. Effective mode therefore runs the raw pass first and keeps its problems.
            Path root = Files.createDirectories(tempDir.resolve("ghosted"));
            Files.writeString(root.resolve("pom.xml"), """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <groupId>org.test</groupId><artifactId>ghosted</artifactId>
                      <version>1.0</version><packaging>pom</packaging>
                      <modules><module>ghost</module></modules>
                    </project>""");

            int exitCode = runEffective(root.resolve("pom.xml"));

            assertEquals(ValidateInvoker.ERROR, exitCode, output.toString());
            assertTrue(
                    output.stream().anyMatch(line -> line.contains("Child subproject ghost")),
                    "the default mode must not pass a project the build cannot read: " + output);
        }

        @Test
        @DisplayName("should refuse an alternate settings file it cannot honour")
        void shouldRefuseAlternateSettings() throws Exception {
            // Resolution runs on the session ApiRunner builds, which reads ~/.m2/settings.xml and
            // nothing else. Accepting the option would resolve through the default file's mirrors
            // and credentials while the caller believed they had redirected it.
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.altUserSettings()).thenReturn(Optional.of("ci-settings.xml"));
            when(options.poms())
                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            assertEquals(ValidateInvoker.BAD_OPERATION, invoker.execute(context));
        }

        @Test
        @DisplayName("should accept an alternate settings file in raw mode, where it changes nothing")
        void shouldAcceptAlternateSettingsInRawMode() throws Exception {
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("raw"));
            when(options.altUserSettings()).thenReturn(Optional.of("ci-settings.xml"));
            when(options.poms())
                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            assertEquals(ValidateInvoker.OK, invoker.execute(context), output.toString());
        }

        @Test
        @DisplayName("should read the parent from an aimed local repository")
        void shouldUseTheAimedLocalRepository() throws Exception {
            // Proved positively, because a failed resolution writes nothing and so says nothing
            // about where it looked: the parent is laid out by hand in the aimed repository and
            // nowhere else, so a clean verdict is only reachable by reading it from there.
            Path repo = tempDir.resolve("aimed");
            Path laid = Files.createDirectories(repo.resolve("org/test/absent/1.0"));
            Files.writeString(laid.resolve("absent-1.0.pom"), """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <groupId>org.test</groupId><artifactId>absent</artifactId>
                      <version>1.0</version><packaging>pom</packaging>
                      <dependencyManagement><dependencies>
                        <dependency>
                          <groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version>
                        </dependency>
                      </dependencies></dependencyManagement>
                    </project>""");
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.localRepository()).thenReturn(Optional.of(repo.toString()));
            when(options.poms())
                    .thenReturn(Optional.of(
                            List.of(project("absent", "<relativePath/>").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            int exitCode = invoker.execute(context);

            assertEquals(ValidateInvoker.OK, exitCode, output.toString());
            assertTrue(
                    output.stream().noneMatch(line -> line.contains("Non-resolvable")),
                    "the aimed repository has the parent, so nothing should go looking: " + output);
        }

        @Test
        @DisplayName("should delete the temporary local repository when the run ends")
        void shouldDeleteTheTemporaryLocalRepository() throws Exception {
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.tempLocalRepository()).thenReturn(Optional.of(true));
            when(options.poms())
                    .thenReturn(Optional.of(
                            List.of(project("absent", "<relativePath/>").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            invoker.execute(context);

            // The directory this run made, not everything in the temp directory: another mvnval
            // on the same machine would fail that.
            Path made = aimed.stream().filter(Objects::nonNull).findFirst().orElse(null);
            assertNotNull(made, "effective mode with --temp-local-repository must aim somewhere");
            assertFalse(Files.exists(made), "a throwaway repository that outlives the run is not throwaway");
        }

        @Test
        @DisplayName("should give the same verdict whichever order the POMs are named in")
        void shouldNotDependOnArgumentOrder() throws Exception {
            // The regression a shared ModelBuilderSession caused: derived sessions share
            // mappedSources, so a decoy declaring the same groupId:artifactId as a real parent
            // answered the child's parent lookup and changed its verdict. Two roots with the same
            // coordinates and different packaging is the shape that broke.
            Path child = project("par", "");
            Path decoy = Files.createDirectories(tempDir.resolve("decoy")).resolve("pom.xml");
            Files.writeString(decoy, """
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                      <modelVersion>4.0.0</modelVersion>
                      <groupId>org.test</groupId><artifactId>par</artifactId>
                      <version>1.0</version><packaging>jar</packaging>
                    </project>""");

            List<String> childFirst = runEffectiveOn(List.of(child.toString(), decoy.toString()));
            List<String> decoyFirst = runEffectiveOn(List.of(decoy.toString(), child.toString()));

            assertEquals(
                    Set.copyOf(childFirst),
                    Set.copyOf(decoyFirst),
                    "the same files must get the same problems whichever order they are given in");
        }

        private List<String> runEffectiveOn(List<String> poms) throws Exception {
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.poms()).thenReturn(Optional.of(poms));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            List<String> lines = new ArrayList<>();
            context.writer = lines::add;
            invoker.execute(context);
            return lines;
        }

        @Test
        @DisplayName("should refuse a local repository that is not a directory")
        void shouldRefuseALocalRepositoryThatIsNotADirectory() throws Exception {
            // Without this the path reaches the resolver and comes back as a cast between two
            // exception types, reported against the POM as though the POM were at fault.
            Path file = Files.writeString(tempDir.resolve("not-a-dir"), "");
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("effective"));
            when(options.localRepository()).thenReturn(Optional.of(file.toString()));
            when(options.poms())
                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            assertEquals(ValidateInvoker.BAD_OPERATION, invoker.execute(context));
        }

        @Test
        @DisplayName("should refuse two different local repositories")
        void shouldRefuseBothLocalRepositoryOptions() throws Exception {
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.localRepository()).thenReturn(Optional.of("somewhere"));
            when(options.tempLocalRepository()).thenReturn(Optional.of(true));
            when(options.poms())
                    .thenReturn(Optional.of(List.of(project("par", "").toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            assertEquals(ValidateInvoker.BAD_OPERATION, invoker.execute(context));
        }

        @Test
        @DisplayName("should leave the same project alone in raw mode")
        void shouldNotResolveInRawMode() throws Exception {
            Path pom = project("absent", "<relativePath/>");
            ValidateOptions options = mock(ValidateOptions.class);
            when(options.mode()).thenReturn(Optional.of("raw"));
            when(options.poms()).thenReturn(Optional.of(List.of(pom.toString())));
            ValidateContext context = TestUtils.createMockContext(tempDir, options);
            context.writer = output::add;

            int exitCode = invoker.execute(context);

            assertEquals(ValidateInvoker.OK, exitCode, output.toString());
            assertTrue(
                    output.stream().noneMatch(line -> line.contains("Non-resolvable")),
                    "raw mode must not go looking for the parent: " + output);
        }
    }
}