TestDiscovery.java

package org.egothor.methodatlas.api;

import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.stream.Stream;

/**
 * Source of discovered test methods for a specific programming language and
 * test framework.
 *
 * <p>
 * Implementations scan a directory tree and emit one {@link DiscoveredMethod}
 * per test method found. The orchestration layer ({@code MethodAtlasApp})
 * programs against this interface; it has no knowledge of how test methods
 * are identified in any particular language or framework.
 * </p>
 *
 * <h2>Platform support model</h2>
 *
 * <p>
 * Each platform has its own implementation in a dedicated sub-package:
 * </p>
 * <ul>
 * <li>{@code discovery.jvm} — Java/Kotlin source files with JUnit, TestNG, …</li>
 * <li>{@code discovery.dotnet} — C# source files with xUnit, NUnit, MSTest</li>
 * <li>{@code discovery.typescript} — TypeScript/JavaScript source files with
 *     Jest, Vitest, Mocha, …</li>
 * <li>{@code discovery.go} — Go source files with the {@code testing} package</li>
 * <li>{@code discovery.python} — Python source files with pytest/unittest</li>
 * <li>{@code discovery.powershell} — PowerShell scripts with Pester</li>
 * <li>{@code discovery.abap} — ABAP source with ABAP Unit / ecATT</li>
 * <li>{@code discovery.cobol} — COBOL source test paragraphs</li>
 * </ul>
 *
 * <p>
 * The list above reflects the platforms that ship today; the canonical,
 * up-to-date matrix is maintained in the project README. Adding a language
 * means implementing this interface in a new module — the core needs no change.
 * </p>
 *
 * <h2>Thread safety</h2>
 *
 * <p>
 * Implementations are <strong>not</strong> required to be thread-safe. The
 * orchestration layer calls {@link #configure(TestDiscoveryConfig)} once and
 * then {@link #discover(Path)} from a single thread, and calls
 * {@link #close()} only after discovery has finished; integrators writing their
 * own orchestrator must observe the same single-threaded lifecycle unless an
 * implementation documents otherwise.
 * </p>
 *
 * <p>
 * {@link TestDiscoveryConfig} is deliberately language-neutral: it carries
 * {@link TestDiscoveryConfig#fileSuffixes() fileSuffixes} (universally
 * applicable), {@link TestDiscoveryConfig#testMarkers() testMarkers}
 * (annotation/attribute names for JVM and .NET; unused for TypeScript), and
 * an open-ended {@link TestDiscoveryConfig#properties() properties} map for
 * plugin-specific settings such as test function names ({@code "test"},
 * {@code "it"}) for a Jest/Mocha plugin.
 * </p>
 *
 * <h2>ServiceLoader integration</h2>
 *
 * <p>
 * Providers are discovered via {@link java.util.ServiceLoader}.  Each provider
 * JAR ships a
 * {@code META-INF/services/org.egothor.methodatlas.api.TestDiscovery}
 * registration file listing its implementation class.  The orchestration layer
 * loads all available providers, calls {@link #configure} on each with the
 * current {@link TestDiscoveryConfig}, and then runs all providers against
 * every scan root, merging their result streams.  This means placing multiple
 * provider JARs on the classpath automatically enables multi-language scanning.
 * </p>
 *
 * <h2>Error handling</h2>
 *
 * <p>
 * Non-fatal per-file errors (e.g. parse failures) should be logged and skipped
 * rather than thrown. {@link #hadErrors()} returns {@code true} after a
 * {@link #discover} call that encountered at least one such error. Fatal errors
 * (e.g. the root directory cannot be traversed) are propagated as
 * {@link IOException}.
 * </p>
 *
 * <h2>Resource management</h2>
 *
 * <p>
 * Implementations that hold long-lived resources (for example a pool of
 * sub-processes) should override {@link #close} to release those resources.
 * The orchestration layer closes every loaded provider when the scan run
 * finishes.  Implementations that hold no external resources may leave the
 * default no-op {@code close} implementation in place.
 * </p>
 *
 * @see DiscoveredMethod
 * @see TestDiscoveryConfig
 *
 * @since 3.0.0
 */
public interface TestDiscovery extends Closeable {

    /**
     * Returns the unique identifier of this discovery provider.
     *
     * <p>
     * The ID is used by the orchestration layer to route
     * {@link TestDiscoveryConfig#fileSuffixesFor(String)} entries that carry a
     * plugin-specific prefix (e.g. {@code "java:Test.java"} targets only the
     * provider whose {@code pluginId()} returns {@code "java"}).
     * </p>
     *
     * <p>
     * IDs must be unique across all providers present on the classpath.
     * The orchestration layer verifies this at startup and throws
     * {@link IllegalStateException} when two providers share the same ID.
     * </p>
     *
     * <p>
     * Convention: use a short, lowercase, hyphen-separated name that matches
     * the target platform (e.g. {@code "java"}, {@code "dotnet"},
     * {@code "typescript"}).
     * </p>
     *
     * @return non-null, non-empty plugin identifier; must be unique across all
     *         loaded providers
     */
    String pluginId();

    /**
     * Configures this provider before the first call to {@link #discover}.
     *
     * <p>
     * The orchestration layer calls this method exactly once after loading the
     * provider via {@link java.util.ServiceLoader} and before any call to
     * {@link #discover}.  Providers that need no runtime configuration may
     * leave this as the default no-op.
     * </p>
     *
     * <p>
     * Providers loaded programmatically (e.g. in tests) may also call this
     * method to (re-)configure an existing instance, or use a constructor that
     * accepts the same information directly.
     * </p>
     *
     * @param config runtime configuration supplied by the calling application;
     *               never {@code null}
     */
    default void configure(TestDiscoveryConfig config) {
        // default: no-op — providers that need no runtime configuration omit this
    }

    /**
     * Scans {@code root} and returns a stream of discovered test methods.
     *
     * <p>
     * The stream is fully materialized before being returned; it is safe to
     * call this method multiple times (e.g. once per scan root).
     * </p>
     *
     * @param root directory to scan
     * @return stream of discovered test methods; never {@code null}
     * @throws IOException if traversing the file tree fails
     */
    Stream<DiscoveredMethod> discover(Path root) throws IOException;

    /**
     * Returns {@code true} if the most recent {@link #discover} call (or any
     * prior call) encountered at least one non-fatal per-file error.
     *
     * @return {@code true} when any file could not be processed
     */
    boolean hadErrors();

    /**
     * Releases any resources held by this provider.
     *
     * <p>
     * The default implementation is a no-op and is suitable for stateless
     * providers that hold no external resources. Implementations that manage
     * long-lived resources (e.g. a pool of sub-processes) must override this
     * method to shut down those resources cleanly.
     * </p>
     *
     * <p>
     * The orchestration layer calls this method once after the last
     * {@link #discover} call has completed.  Providers that register JVM
     * shutdown hooks as a backstop should remove those hooks here to avoid
     * spurious execution after an explicit {@code close()}.
     * </p>
     *
     * @throws IOException if releasing a resource fails
     */
    @Override
    default void close() throws IOException {
        // default: no-op — providers that hold no long-lived resources omit this
    }
}