PluginLoader.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.command;

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.egothor.methodatlas.api.SourcePatcher;
import org.egothor.methodatlas.api.TestDiscovery;
import org.egothor.methodatlas.api.TestDiscoveryConfig;

/**
 * Resolves and configures discovery plugins via {@link ServiceLoader}.
 *
 * <p>
 * Each plugin JAR ships a service registration file under
 * {@code META-INF/services/} listing its implementation of
 * {@link TestDiscovery} (and, optionally, {@link SourcePatcher}). This loader
 * walks the classpath, instantiates every registered provider, applies the
 * run-time {@link TestDiscoveryConfig} via {@code configure}, and verifies that
 * every provider declares a unique {@code pluginId()}.
 * </p>
 *
 * <h2>Lifecycle</h2>
 *
 * <p>
 * Instances are intended to be created once per CLI run and injected into the
 * {@link Command} implementations that need them. The loader itself is
 * stateless — no instance fields — so a single loader can be shared between
 * commands that participate in the same orchestration. The lifecycle of the
 * loaded providers is owned by the caller: a typical usage closes them in a
 * {@code finally} block via {@link #closeAll(List)}.
 * </p>
 *
 * <h2>Thread safety</h2>
 *
 * <p>
 * This class is thread-safe. {@link ServiceLoader#load(Class)} resolution is
 * idempotent per classloader, and no shared mutable state is maintained.
 * </p>
 *
 * @see TestDiscovery
 * @see SourcePatcher
 * @see Command
 * @since 1.0.0
 */
public final class PluginLoader {

    private static final Logger LOG = Logger.getLogger(PluginLoader.class.getName());

    /**
     * Creates a new plugin loader. The loader carries no instance state and is
     * safe to share across commands within a single CLI run.
     */
    public PluginLoader() {
        // Intentionally empty; PluginLoader is stateless.
    }

    /**
     * Loads all {@link TestDiscovery} providers registered via
     * {@link ServiceLoader}, configures each one with {@code config}, and
     * returns them in registration order.
     *
     * <p>
     * The returned providers are open resources. Callers must close them
     * through {@link #closeAll(List)} in a {@code finally} block to release
     * any per-provider resources (file handles, native processes, etc.).
     * </p>
     *
     * <p>
     * Time complexity is {@code O(p)} in the number of providers; the
     * ServiceLoader lookup itself is dominated by classpath scanning.
     * </p>
     *
     * @param config run-time configuration forwarded to every provider via
     *               {@link TestDiscovery#configure}; must not be {@code null}
     * @return non-empty list of configured providers in registration order
     * @throws IllegalStateException if no providers are found on the classpath,
     *                               or if two providers share the same
     *                               {@link TestDiscovery#pluginId()}
     */
    @SuppressWarnings("PMD.CloseResource") // callers own the lifecycle and must close via closeAll()
    public List<TestDiscovery> loadProviders(TestDiscoveryConfig config) {
        List<TestDiscovery> providers = new ArrayList<>();
        for (TestDiscovery provider : ServiceLoader.load(TestDiscovery.class)) {
            provider.configure(config);
            providers.add(provider);
        }
        if (providers.isEmpty()) {
            throw new IllegalStateException(
                    "No TestDiscovery providers found on the classpath. "
                    + "Ensure at least one provider JAR ships the service registration file "
                    + "META-INF/services/org.egothor.methodatlas.api.TestDiscovery.");
        }
        requireUniqueDiscoveryIds(providers);
        return providers;
    }

    /**
     * Loads all {@link SourcePatcher} providers registered via
     * {@link ServiceLoader}, configures each one with {@code config}, and
     * returns them in registration order.
     *
     * <p>
     * Unlike {@link #loadProviders}, returning an empty list is legitimate:
     * languages that do not support source write-back (such as TypeScript or
     * Python) ship no patcher.
     * </p>
     *
     * @param config run-time configuration forwarded to every patcher via
     *               {@link SourcePatcher#configure}; must not be {@code null}
     * @return possibly-empty list of configured patchers in registration order
     * @throws IllegalStateException if two patchers share the same
     *                               {@link SourcePatcher#pluginId()}
     */
    public List<SourcePatcher> loadPatchers(TestDiscoveryConfig config) {
        List<SourcePatcher> patchers = new ArrayList<>();
        for (SourcePatcher patcher : ServiceLoader.load(SourcePatcher.class)) {
            patcher.configure(config);
            patchers.add(patcher);
        }
        requireUniquePatcherIds(patchers);
        return patchers;
    }

    /**
     * Closes every provider in the list, logging any {@link IOException} at
     * {@link Level#FINE} and continuing so that all providers are attempted.
     *
     * <p>
     * This method never throws: a provider whose {@code close} fails leaves
     * its resources in an indeterminate state, but the orchestration layer
     * always exits cleanly. Failures are observable through the FINE-level
     * log.
     * </p>
     *
     * @param providers list of providers to close; must not be {@code null}
     */
    @SuppressWarnings("PMD.CloseResource") // this method IS the close mechanism; p.close() is called explicitly
    public void closeAll(List<TestDiscovery> providers) {
        for (TestDiscovery p : providers) {
            try {
                p.close();
            } catch (IOException e) {
                if (LOG.isLoggable(Level.FINE)) {
                    LOG.log(Level.FINE, "Failed to close provider " + p.pluginId(), e);
                }
            }
        }
    }

    /**
     * Verifies that every {@link TestDiscovery} provider in the list has a
     * unique {@link TestDiscovery#pluginId()}.
     *
     * <p>
     * This method is {@code static} because it is a pure validation with no
     * instance dependencies — test code calls it directly with handcrafted
     * provider lists, and the instance loader calls it after a
     * {@code ServiceLoader} sweep. Time complexity is {@code O(p)} in the
     * number of providers.
     * </p>
     *
     * @param providers list of providers to validate; must not be {@code null}
     * @throws IllegalStateException if two or more providers share the same id
     */
    @SuppressWarnings("PMD.CloseResource") // providers are owned by the caller; this method does not close them
    public static void requireUniqueDiscoveryIds(List<TestDiscovery> providers) {
        Set<String> seen = new LinkedHashSet<>();
        for (TestDiscovery p : providers) {
            String id = p.pluginId();
            if (!seen.add(id)) {
                throw new IllegalStateException(
                        "Duplicate TestDiscovery plugin ID \"" + id + "\": two or more "
                        + "registered providers claim the same pluginId(). "
                        + "Each provider must declare a unique identifier.");
            }
        }
    }

    /**
     * Verifies that every {@link SourcePatcher} in the list has a unique
     * {@link SourcePatcher#pluginId()}.
     *
     * @param patchers list of patchers to validate; must not be {@code null}
     * @throws IllegalStateException if two or more patchers share the same id
     */
    public static void requireUniquePatcherIds(List<SourcePatcher> patchers) {
        Set<String> seen = new LinkedHashSet<>();
        for (SourcePatcher p : patchers) {
            String id = p.pluginId();
            if (!seen.add(id)) {
                throw new IllegalStateException(
                        "Duplicate SourcePatcher plugin ID \"" + id + "\": two or more "
                        + "registered patchers claim the same pluginId(). "
                        + "Each patcher must declare a unique identifier.");
            }
        }
    }
}