SourcePatcher.java

package org.egothor.methodatlas.api;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;

/**
 * Language-specific service for writing test-classification metadata back into
 * source files.
 *
 * <p>
 * Implementations are discovered at runtime via {@link java.util.ServiceLoader}.
 * Each implementation handles the source files for one language or framework
 * by translating the language-neutral tag and display-name maps into the
 * correct annotation or attribute syntax for that language.
 * </p>
 *
 * <p>
 * A plugin that does not support source write-back (e.g. a TypeScript plugin
 * where no annotation system exists) simply does not register an implementation.
 * </p>
 *
 * <h2>ServiceLoader registration</h2>
 * <p>
 * Each provider JAR must contain a UTF-8 text file at:<br>
 * {@code META-INF/services/org.egothor.methodatlas.api.SourcePatcher}<br>
 * listing one fully qualified implementation class name per line.
 * </p>
 *
 * <h2>Lifecycle</h2>
 * <ol>
 *   <li>{@link #configure(TestDiscoveryConfig)} is called once after the
 *       instance is loaded, before any call to {@link #supports} or
 *       {@link #patch}.</li>
 *   <li>{@link #supports(Path)} is called to determine whether this patcher
 *       handles a given source file.</li>
 *   <li>{@link #patch} is called for each file that {@link #supports} accepted,
 *       with the tags and display names to write.</li>
 * </ol>
 *
 * @see TestDiscovery
 * @see TestDiscoveryConfig
 *
 * @since 3.0.0
 */
public interface SourcePatcher {

    /**
     * Returns the unique identifier of this source-patcher provider.
     *
     * <p>
     * Mirrors {@link TestDiscovery#pluginId()}: the ID is used to route
     * {@link TestDiscoveryConfig#fileSuffixesFor(String)} entries and to
     * enforce uniqueness at startup. Two patchers with the same ID cause an
     * {@link IllegalStateException} during provider loading.
     * </p>
     *
     * @return non-null, non-empty plugin identifier; must be unique across all
     *         loaded patchers
     * @see TestDiscovery#pluginId()
     */
    String pluginId();

    /**
     * Receives the runtime configuration built from CLI arguments and the YAML
     * config file.
     *
     * <p>
     * Called once after the instance is loaded by {@link java.util.ServiceLoader},
     * before the first call to {@link #supports} or {@link #patch}.
     * The default implementation is a no-op; override when the patcher needs
     * access to {@link TestDiscoveryConfig#testMarkers()} or
     * {@link TestDiscoveryConfig#fileSuffixes()} to decide which files it owns.
     * </p>
     *
     * @param config runtime configuration; never {@code null}
     */
    default void configure(TestDiscoveryConfig config) {
        // no-op by default
    }

    /**
     * Returns {@code true} if this patcher can handle the given source file.
     *
     * <p>
     * The orchestration layer calls this method for every file it wants to patch
     * and forwards the file only to the first patcher that returns {@code true}.
     * Implementations typically check the file-name suffix against the configured
     * {@link TestDiscoveryConfig#fileSuffixes()}.
     * </p>
     *
     * @param sourceFile path to the candidate source file; never {@code null}
     * @return {@code true} if this patcher accepts the file
     */
    boolean supports(Path sourceFile);

    /**
     * Returns the test methods found in the given source file, grouped by their
     * fully qualified class name, or an empty map when the file contains no test
     * methods or when this implementation does not support source inventory.
     *
     * <p>
     * The orchestration layer uses this method to build a source-method inventory
     * for mismatch detection between the CSV desired-state and the actual source
     * tree. Implementations that support source inventory override this method;
     * the default no-op implementation returns an empty map, which causes the
     * file to be excluded from mismatch counting.
     * </p>
     *
     * <p>
     * The map key is the fully qualified class name and the value is the list of
     * simple method names declared in that class.
     * </p>
     *
     * @param sourceFile path to the source file to inspect; never {@code null}
     * @return map from FQCN to list of simple method names; never {@code null};
     *         may be empty
     * @throws IOException if the file cannot be read
     */
    default Map<String, List<String>> discoverMethodsByClass(Path sourceFile) throws IOException {
        return Map.of();
    }

    /**
     * Writes tags and display names back into the source file.
     *
     * <p>
     * The implementation locates each test method named in {@code tagsToApply}
     * or {@code displayNames} and adds or replaces the appropriate annotation or
     * attribute in the source. Methods that appear in neither map are left
     * unchanged. Files that contain no matching methods should be left unchanged.
     * </p>
     *
     * <p>
     * Implementations are expected to preserve all existing source formatting
     * that is not directly related to the tags or display names being written.
     * </p>
     *
     * @param sourceFile   path to the source file to patch; the file is
     *                     overwritten in place on success
     * @param tagsToApply  map from test-method simple name to the list of tag
     *                     values to write; may be empty
     * @param displayNames map from test-method simple name to the display-name
     *                     string to write; may be empty
     * @param diagnostics  writer for human-readable diagnostic output; never
     *                     {@code null}
     * @return number of annotation changes made; {@code 0} if the file was not
     *         modified
     * @throws IOException if the file cannot be read or written
     */
    int patch(Path sourceFile,
              Map<String, List<String>> tagsToApply,
              Map<String, String> displayNames,
              PrintWriter diagnostics) throws IOException;
}