TestDiscoveryConfig.java
package org.egothor.methodatlas.api;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Runtime configuration supplied to a {@link TestDiscovery} provider when it
* is loaded via {@link java.util.ServiceLoader}.
*
* <p>
* An instance is built from the parsed command-line options and passed to
* {@link TestDiscovery#configure} before the first call to
* {@link TestDiscovery#discover}. Providers use the values to set up
* file-selection and test-identification behaviour specific to their target
* language or test framework.
* </p>
*
* <h2>Fields</h2>
*
* <ul>
* <li>{@link #fileSuffixes} — file-name suffixes that select source files to
* parse (e.g. {@code ["Test.java"]}, {@code [".test.ts"]}, {@code [".cs"]}).
* Entries may optionally be prefixed with a plugin ID and
* {@link #PLUGIN_ID_SEPARATOR} to target a specific plugin:
* {@code "java:Test.java"} is delivered only to the {@code java} plugin,
* while {@code "Test.java"} (no separator) is delivered to every plugin.
* Use {@link #fileSuffixesFor(String)} to resolve the list for a given
* plugin ID.</li>
* <li>{@link #testMarkers} — language-neutral identifiers that mark test
* methods. The semantics are provider-defined: for JVM providers these are
* annotation simple names ({@code "Test"}, {@code "ParameterizedTest"});
* for .NET providers they are attribute names ({@code "Fact"},
* {@code "Test"}); for TypeScript providers this set is typically empty
* (test functions are identified by name rather than annotation).
* An empty set means "use provider defaults".</li>
* <li>{@link #properties} — arbitrary key/value pairs for provider-specific
* settings that cannot be captured by {@code fileSuffixes} or
* {@code testMarkers}. Examples: {@code functionNames → ["test", "it"]}
* for a Jest/Mocha plugin, {@code traitFilters → ["Category=Security"]}
* for a .NET NUnit plugin. Providers ignore keys they do not recognise.
* An empty map means "use provider defaults" for all plugin-specific
* settings.</li>
* </ul>
*
* @param fileSuffixes file-name suffixes used to select source files;
* never {@code null}; may be empty
* @param testMarkers language-neutral identifiers that mark test methods;
* never {@code null}; empty means "use provider
* defaults / auto-detect"
* @param properties plugin-specific key/multi-value pairs; never
* {@code null}; empty means "use provider defaults"
*
* @see TestDiscovery#configure
* @see TestDiscovery
* @since 3.0.0
*/
public record TestDiscoveryConfig(
List<String> fileSuffixes,
Set<String> testMarkers,
Map<String, List<String>> properties) {
/**
* Separator character used to target a suffix entry at a specific plugin.
*
* <p>
* A {@link #fileSuffixes} entry of the form {@code "<pluginId>:<suffix>"}
* is delivered only to the plugin whose {@link TestDiscovery#pluginId()}
* equals {@code <pluginId>}. An entry that does not contain this character
* is treated as a global entry and delivered to every plugin.
* </p>
*
* <p>
* The colon was chosen because it never appears in a valid file name on
* any mainstream operating system (Windows, macOS, Linux).
* </p>
*
* @see #fileSuffixesFor(String)
*/
public static final char PLUGIN_ID_SEPARATOR = ':';
/**
* Compact constructor that defensively copies all three collections.
*
* <p>
* {@code properties} is deep-copied: each inner list is made unmodifiable
* before the outer map is made unmodifiable, so callers cannot mutate
* per-key value lists through a retained reference.
* </p>
*
* @param fileSuffixes file-name suffixes; copied to an unmodifiable list
* @param testMarkers test-marker identifiers; copied to an unmodifiable set
* @param properties plugin-specific entries; deep-copied to an
* unmodifiable map of unmodifiable lists
*/
public TestDiscoveryConfig {
fileSuffixes = List.copyOf(fileSuffixes);
testMarkers = Set.copyOf(testMarkers);
properties = properties.entrySet().stream()
.collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
e -> List.copyOf(e.getValue())));
}
/**
* Returns the file-name suffixes that apply to the given plugin.
*
* <p>
* Each entry in {@link #fileSuffixes} is resolved as follows:
* </p>
* <ul>
* <li>If the entry does not contain {@link #PLUGIN_ID_SEPARATOR}, it is a
* <em>global</em> entry: included in the result for every plugin.</li>
* <li>If the entry has the form {@code "<id>:<suffix>"} and {@code <id>}
* equals {@code pluginId}, the {@code <suffix>} part is included.</li>
* <li>Entries targeting a different plugin ID are silently skipped.</li>
* <li>Entries that produce an empty suffix after stripping the prefix are
* also skipped.</li>
* </ul>
*
* <p>
* Plugins call this method inside their {@link TestDiscovery#configure}
* implementation instead of reading {@link #fileSuffixes()} directly.
* </p>
*
* @param pluginId the {@link TestDiscovery#pluginId()} of the caller;
* never {@code null}
* @return unmodifiable list of suffix strings for the given plugin;
* may be empty when no global or plugin-specific entries are present
*/
public List<String> fileSuffixesFor(String pluginId) {
return fileSuffixes.stream()
.filter(entry -> {
int sep = entry.indexOf(PLUGIN_ID_SEPARATOR);
return sep < 0 || entry.substring(0, sep).equals(pluginId);
})
.map(entry -> {
int sep = entry.indexOf(PLUGIN_ID_SEPARATOR);
return sep < 0 ? entry : entry.substring(sep + 1);
})
.filter(s -> !s.isEmpty())
.collect(Collectors.toUnmodifiableList());
}
}