JavaSourcePatcher.java
package org.egothor.methodatlas.discovery.jvm;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import org.egothor.methodatlas.api.SourcePatcher;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.nodeTypes.NodeWithName;
import com.github.javaparser.printer.lexicalpreservation.LexicalPreservingPrinter;
/**
* {@link SourcePatcher} implementation for Java source files.
*
* <p>
* Applies annotation changes (tags and display names) to Java test source
* files driven by a reviewed MethodAtlas CSV export. This class contains the
* logic for writing tag and display-name annotations back into {@code .java}
* source files using JavaParser's lexical-preserving printer so that
* unrelated formatting is left intact.
* </p>
*
* <h2>JUnit version detection and configuration</h2>
* <p>
* The patcher detects the test framework for each file from its import
* declarations and applies the appropriate annotation style:
* </p>
* <ul>
* <li><strong>JUnit 5 (Jupiter)</strong> — writes {@code @Tag("value")}
* and {@code @DisplayName("text")}.</li>
* <li><strong>JUnit 4</strong> — writes {@code @Category(SomeClass.class)};
* {@code @DisplayName} has no JUnit 4 equivalent and is skipped with a
* diagnostic. The tag-to-class mapping must be supplied by the operator
* via the {@code categoryClasses} property (see below).</li>
* </ul>
* <p>
* When no recognisable framework imports are found in a file, the patcher
* falls back to a configured default (default: JUnit 5). The default can be
* overridden via the {@code tagFramework} property in
* {@link TestDiscoveryConfig#properties()}.
* </p>
*
* <h2>Plugin properties consumed by this patcher</h2>
* <dl>
* <dt>{@code tagFramework}</dt>
* <dd>Single value: {@code junit5} (default) or {@code junit4}. Determines
* which annotation style is used when no framework imports are found in
* a source file. Command-line: {@code -property tagFramework=junit4}.
* YAML:
* <pre>
* properties:
* tagFramework:
* - junit4
* </pre>
* </dd>
* <dt>{@code categoryClasses}</dt>
* <dd>List of {@code tagName=fully.qualified.ClassName} entries that map
* each MethodAtlas tag string to the fully qualified name of the JUnit 4
* {@code @Category} class to write. Required for JUnit 4 files; ignored
* for JUnit 5 files. Tags without a mapping in JUnit 4 mode are skipped
* with a warning. Command-line:
* {@code -property categoryClasses=security=com.example.SecurityTest}.
* YAML:
* <pre>
* properties:
* categoryClasses:
* - security=com.example.SecurityTest
* - performance=com.example.PerformanceTest
* </pre>
* </dd>
* </dl>
*
* <h2>ServiceLoader registration</h2>
* <p>
* This class is registered as a {@link SourcePatcher} provider via
* {@code META-INF/services/org.egothor.methodatlas.api.SourcePatcher}.
* The orchestration layer loads it automatically via
* {@link java.util.ServiceLoader}.
* </p>
*
* @see SourcePatcher
* @see AnnotationInspector
* @see JavaTestFramework
*/
@SuppressWarnings("PMD.CyclomaticComplexity")
public final class JavaSourcePatcher implements SourcePatcher {
private static final Logger LOG = Logger.getLogger(JavaSourcePatcher.class.getName());
/** Threshold used to choose between single-class and array {@code @Category} syntax. */
private static final int ONE_CATEGORY = 1;
/** Fully qualified name of {@code @DisplayName} for import management. */
/* default */ static final String IMPORT_DISPLAY_NAME = "org.junit.jupiter.api.DisplayName";
/** Fully qualified name of {@code @Tag} for import management. */
/* default */ static final String IMPORT_TAG = "org.junit.jupiter.api.Tag";
/** Fully qualified name of {@code @Category} for import management. */
/* default */ static final String IMPORT_CATEGORY = "org.junit.experimental.categories.Category";
private static final String ANNOTATION_DISPLAY_NAME = "DisplayName";
private static final String ANNOTATION_TAG = "Tag";
private static final String ANNOTATION_CATEGORY = "Category";
/** Pattern that a fully qualified class name must match. */
private static final Pattern FQCN_PATTERN =
Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*");
private List<String> fileSuffixes = List.of("Test.java");
/** Default framework used when no framework imports are detected in a file. */
private JavaTestFramework defaultFramework = JavaTestFramework.JUNIT5;
/** Maps MethodAtlas tag string → fully qualified category class name (JUnit 4 mode). */
private Map<String, String> categoryClassMap = Map.of();
/**
* Reusable JavaParser (Java 21 language level). JavaParser resets its state
* on each {@code parse} call, so a single instance serves every sequential
* parse; the patcher is used single-threaded, mirroring
* {@code JavaTestDiscovery}.
*/
private final JavaParser parser;
/**
* No-arg constructor required by {@link java.util.ServiceLoader}.
*/
public JavaSourcePatcher() {
ParserConfiguration cfg = new ParserConfiguration();
cfg.setLanguageLevel(LanguageLevel.JAVA_21);
this.parser = new JavaParser(cfg);
}
/**
* {@inheritDoc}
*/
@Override
public String pluginId() {
return "java";
}
/**
* {@inheritDoc}
*
* <p>
* Stores the configured file suffixes used by {@link #supports(Path)}.
* Suffixes that target other plugins (e.g. {@code "dotnet:Test.cs"}) are
* automatically excluded via
* {@link TestDiscoveryConfig#fileSuffixesFor(String)}.
* When no global or {@code "java:"}-prefixed entries remain, the default
* suffix {@code "Test.java"} is used.
* </p>
*
* <p>
* Also reads JUnit-version and category-class-mapping properties from
* {@link TestDiscoveryConfig#properties()}:
* </p>
* <ul>
* <li>{@code tagFramework} — single value {@code junit5} or {@code junit4};
* sets the fallback framework for files without recognisable imports</li>
* <li>{@code categoryClasses} — list of {@code tagName=fqcn} entries
* mapping each tag string to a JUnit 4 {@code @Category} class</li>
* </ul>
*/
@Override
public void configure(TestDiscoveryConfig config) {
List<String> suffixes = config.fileSuffixesFor(pluginId());
this.fileSuffixes = suffixes.isEmpty() ? List.of("Test.java") : suffixes;
List<String> frameworkProp = config.properties().getOrDefault("tagFramework", List.of());
if (!frameworkProp.isEmpty()) {
String value = frameworkProp.get(0).toLowerCase(Locale.ROOT);
switch (value) {
case "junit4" -> this.defaultFramework = JavaTestFramework.JUNIT4;
case "junit5" -> this.defaultFramework = JavaTestFramework.JUNIT5;
default -> {
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Unknown tagFramework value '" + frameworkProp.get(0)
+ "'; expected 'junit4' or 'junit5' — defaulting to junit5");
}
}
}
}
List<String> classMappings = config.properties().getOrDefault("categoryClasses", List.of());
Map<String, String> map = new LinkedHashMap<>();
for (String entry : classMappings) {
int eq = entry.indexOf('=');
if (eq <= 0 || eq == entry.length() - 1) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Invalid categoryClasses entry '" + entry
+ "'; expected 'tagName=fqcn' format — skipped");
}
continue;
}
String tagName = entry.substring(0, eq).strip();
String fqcn = entry.substring(eq + 1).strip();
if (!FQCN_PATTERN.matcher(fqcn).matches()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Invalid FQCN '" + fqcn
+ "' in categoryClasses entry '" + entry + "' — skipped");
}
continue;
}
map.put(tagName, fqcn);
}
this.categoryClassMap = Collections.unmodifiableMap(map);
}
/**
* {@inheritDoc}
*
* <p>
* Returns {@code true} if the source file's name ends with any of the
* configured file suffixes (default: {@code "Test.java"}).
* </p>
*/
@Override
public boolean supports(Path sourceFile) {
Path fileNamePath = sourceFile.getFileName();
if (fileNamePath == null) {
return false;
}
String name = fileNamePath.toString();
return fileSuffixes.stream().anyMatch(name::endsWith);
}
/**
* {@inheritDoc}
*
* <p>
* Parses the source file with JavaParser (Java 21 language level) and
* returns a map from FQCN to the list of simple test-method names declared
* in each class. Methods are identified using
* {@link AnnotationInspector#isJUnitTest} with the default test annotations.
* </p>
*/
@Override
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
public Map<String, List<String>> discoverMethodsByClass(Path sourceFile) throws IOException {
ParseResult<CompilationUnit> parseResult = parser.parse(sourceFile);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("discoverMethodsByClass: failed to parse: " + sourceFile
+ " — " + parseResult.getProblems());
}
return Map.of();
}
CompilationUnit cu = parseResult.getResult().orElseThrow();
String packageName = cu.getPackageDeclaration()
.map(NodeWithName::getNameAsString).orElse("");
Set<String> effective = AnnotationInspector.effectiveAnnotations(
cu, AnnotationInspector.DEFAULT_TEST_ANNOTATIONS);
Map<String, List<String>> result = new LinkedHashMap<>();
for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
String fqcn = buildFqcn(packageName, clazz.getNameAsString());
List<String> names = new ArrayList<>();
for (MethodDeclaration method : clazz.getMethods()) {
if (AnnotationInspector.isJUnitTest(method, effective)) {
names.add(method.getNameAsString());
}
}
if (!names.isEmpty()) {
result.put(fqcn, names);
}
}
return result;
}
/**
* {@inheritDoc}
*
* <p>
* Parses the source file with JavaParser (Java 21 language level) and
* applies the desired annotation state to each matching test method.
* The file is written back using {@link LexicalPreservingPrinter} so that
* formatting outside the modified annotations is preserved.
* </p>
*
* <p>
* The test framework is detected from the file's import declarations via
* {@link AnnotationInspector#detectFramework}; when no framework imports are
* present the configured {@code tagFramework} default is used.
* </p>
*
* <p>
* <strong>JUnit 5 mode</strong> — rewrites {@code @Tag} / {@code @Tags}
* annotations and optionally {@code @DisplayName}.<br>
* <strong>JUnit 4 mode</strong> — rewrites {@code @Category} /
* {@code @Categories} annotations; {@code @DisplayName} has no JUnit 4
* equivalent and is skipped with a diagnostic line per affected method.
* </p>
*
* @return number of annotation changes made; {@code 0} if the file was not
* modified
*/
@Override
public int patch(Path sourceFile,
Map<String, List<String>> tagsToApply,
Map<String, String> displayNames,
PrintWriter diagnostics) throws IOException {
ParseResult<CompilationUnit> parseResult = parser.parse(sourceFile);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("Failed to parse: " + sourceFile + " — " + parseResult.getProblems());
}
return 0;
}
CompilationUnit cu = parseResult.getResult().orElseThrow();
LexicalPreservingPrinter.setup(cu);
String packageName = cu.getPackageDeclaration()
.map(NodeWithName::getNameAsString).orElse("");
JavaTestFramework framework = AnnotationInspector.detectFramework(cu).orElse(defaultFramework);
Set<String> effective = AnnotationInspector.effectiveAnnotations(
cu, AnnotationInspector.DEFAULT_TEST_ANNOTATIONS);
boolean needsTagImport = false;
boolean needsDisplayNameImport = false;
boolean needsCatImport = false;
Set<String> categoryClassImports = new LinkedHashSet<>();
int totalChanges = 0;
for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
String fqcn = buildFqcn(packageName, clazz.getNameAsString());
for (MethodDeclaration method : clazz.getMethods()) {
if (!AnnotationInspector.isJUnitTest(method, effective)) {
continue;
}
String methodName = method.getNameAsString();
List<String> desiredTags = tagsToApply.get(methodName);
String desiredDisplayName = displayNames.get(methodName);
if (desiredTags == null && !displayNames.containsKey(methodName)) {
continue;
}
MethodApplyResult result = dispatchMethodPatch(
method, fqcn + "#" + methodName, desiredTags, desiredDisplayName,
framework, diagnostics);
if (result.modified()) {
int changes = result.tagsAdded() + result.tagsRemoved()
+ (result.displayNameChanged() ? 1 : 0);
totalChanges += changes;
if (result.needsTagImport()) {
needsTagImport = true;
}
if (result.needsDisplayNameImport()) {
needsDisplayNameImport = true;
}
if (result.needsCategoryImport()) {
needsCatImport = true;
categoryClassImports.addAll(result.addedCategoryFqcns());
}
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("Patched method " + fqcn + "#" + methodName + ": " + changes + " change(s)");
}
}
}
}
if (totalChanges > 0) {
writeImports(cu, needsTagImport, needsDisplayNameImport, needsCatImport, categoryClassImports);
Files.writeString(sourceFile, LexicalPreservingPrinter.print(cu), StandardCharsets.UTF_8);
diagnostics.println("Patched: " + sourceFile + " (+" + totalChanges + " change(s))");
}
return totalChanges;
}
private MethodApplyResult dispatchMethodPatch(
MethodDeclaration method,
String methodLabel,
List<String> desiredTags,
String desiredDisplayName,
JavaTestFramework framework,
PrintWriter diagnostics) {
if (framework == JavaTestFramework.JUNIT4) {
if (desiredDisplayName != null && !desiredDisplayName.isEmpty()) {
diagnostics.println("[WARN] @DisplayName not supported for JUnit 4 method "
+ methodLabel + " — skipped");
}
return applyDesiredStateJunit4(method, desiredTags, categoryClassMap, diagnostics);
}
return applyDesiredState(method, desiredTags, desiredDisplayName);
}
private static void writeImports(CompilationUnit cu,
boolean needsTag, boolean needsDisplayName,
boolean needsCat, Set<String> categoryFqcns) {
if (needsTag) {
cu.addImport(IMPORT_TAG);
}
if (needsDisplayName) {
cu.addImport(IMPORT_DISPLAY_NAME);
}
if (needsCat) {
cu.addImport(IMPORT_CATEGORY);
categoryFqcns.forEach(cu::addImport);
}
}
/**
* Applies a desired JUnit 5 annotation state to a single test method declaration.
*
* <p>All existing {@code @Tag} and {@code @Tags} annotations are removed and
* replaced with exactly the tags from {@code desiredTags}. The
* {@code @DisplayName} annotation is driven by {@code desiredDisplayName}
* according to a three-way contract:</p>
* <ul>
* <li>{@code null} — column was absent from the source CSV (old format):
* the existing {@code @DisplayName} annotation is left untouched</li>
* <li>{@code ""} — column was present but empty: any existing
* {@code @DisplayName} is removed</li>
* <li>non-empty text — the desired display name: any existing
* {@code @DisplayName} is replaced with the new value</li>
* </ul>
*
* @param method method declaration to modify
* @param desiredTags exact set of {@code @Tag} values to apply; {@code null}
* is treated as an empty list (all tags removed)
* @param desiredDisplayName desired {@code @DisplayName} text; {@code null} means
* leave unchanged; {@code ""} means remove; non-empty
* means set to this value
* @return result describing what changed; never {@code null}
*/
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
/* default */ static MethodApplyResult applyDesiredState(MethodDeclaration method,
List<String> desiredTags, String desiredDisplayName) {
// Handle @DisplayName
// null → column absent from CSV (old format): leave @DisplayName unchanged
// "" → column present but empty: remove @DisplayName
// text → set @DisplayName to the given text
boolean displayNameChanged = false;
if (desiredDisplayName != null && !desiredDisplayName.isEmpty()) {
method.getAnnotations().removeIf(a -> ANNOTATION_DISPLAY_NAME.equals(a.getNameAsString()));
method.addSingleMemberAnnotation(ANNOTATION_DISPLAY_NAME,
new StringLiteralExpr(desiredDisplayName));
displayNameChanged = true;
} else if (desiredDisplayName != null) {
// desiredDisplayName is "" — remove any existing @DisplayName
boolean hadDisplayName = method.getAnnotations().stream()
.anyMatch(a -> ANNOTATION_DISPLAY_NAME.equals(a.getNameAsString()));
method.getAnnotations().removeIf(a -> ANNOTATION_DISPLAY_NAME.equals(a.getNameAsString()));
if (hadDisplayName) {
displayNameChanged = true;
}
}
// else desiredDisplayName == null → no change to @DisplayName
// Handle @Tag annotations — only mutate the AST if the sets differ.
Set<String> existingTags = new HashSet<>(AnnotationInspector.getTagValues(method));
Set<String> desiredTagSet = new HashSet<>();
if (desiredTags != null) {
for (String tag : desiredTags) {
if (tag != null && !tag.isBlank()) {
desiredTagSet.add(tag);
}
}
}
int tagsAdded = 0;
int tagsRemoved = 0;
if (!existingTags.equals(desiredTagSet)) {
method.getAnnotations().removeIf(a -> ANNOTATION_TAG.equals(a.getNameAsString())
|| "Tags".equals(a.getNameAsString()));
tagsRemoved = existingTags.size();
for (String tag : desiredTagSet) {
method.addSingleMemberAnnotation(ANNOTATION_TAG, new StringLiteralExpr(tag));
tagsAdded++;
}
}
return new MethodApplyResult(tagsAdded, tagsRemoved, displayNameChanged, Set.of());
}
/**
* Applies a desired JUnit 4 annotation state to a single test method declaration.
*
* <p>All existing {@code @Category} and {@code @Categories} annotations are
* removed and replaced with exactly the categories resolved from
* {@code desiredTags} via {@code categoryClassMap}. Tags that have no mapping
* in {@code categoryClassMap} are skipped with a warning written to both the
* JUL logger and {@code diagnostics}.</p>
*
* <p>{@code @DisplayName} is not touched by this method; the caller is
* responsible for issuing the appropriate diagnostic when display names are
* requested for a JUnit 4 method.</p>
*
* @param method method declaration to modify
* @param desiredTags tags to apply; {@code null} treated as empty (all
* categories removed)
* @param categoryClassMap mapping from tag string to fully qualified category
* class name
* @param diagnostics writer for human-readable diagnostic output
* @return result describing what changed; never {@code null}
*/
/* default */ static MethodApplyResult applyDesiredStateJunit4(
MethodDeclaration method,
List<String> desiredTags,
Map<String, String> categoryClassMap,
PrintWriter diagnostics) {
Set<String> existingSimpleNames = getCategorySimpleNames(method);
Set<String> desiredFqcns = resolveCategoryFqcns(desiredTags, categoryClassMap, diagnostics);
Set<String> desiredSimpleNames = new LinkedHashSet<>();
for (String fqcn : desiredFqcns) {
desiredSimpleNames.add(simpleNameOf(fqcn));
}
if (existingSimpleNames.equals(desiredSimpleNames)) {
return new MethodApplyResult(0, 0, false, Set.of());
}
method.getAnnotations().removeIf(a -> ANNOTATION_CATEGORY.equals(a.getNameAsString())
|| "Categories".equals(a.getNameAsString()));
int tagsRemoved = existingSimpleNames.size();
if (desiredFqcns.isEmpty()) {
return new MethodApplyResult(0, tagsRemoved, false, Set.of());
}
if (desiredFqcns.size() == ONE_CATEGORY) {
String simpleName = desiredSimpleNames.iterator().next();
method.addSingleMemberAnnotation(ANNOTATION_CATEGORY,
StaticJavaParser.parseExpression(simpleName + ".class"));
} else {
ArrayInitializerExpr array = new ArrayInitializerExpr();
for (String simpleName : desiredSimpleNames) {
array.getValues().add(StaticJavaParser.parseExpression(simpleName + ".class"));
}
method.addSingleMemberAnnotation(ANNOTATION_CATEGORY, array);
}
return new MethodApplyResult(desiredFqcns.size(), tagsRemoved, false, Set.copyOf(desiredFqcns));
}
private static Set<String> resolveCategoryFqcns(
List<String> desiredTags,
Map<String, String> categoryClassMap,
PrintWriter diagnostics) {
if (desiredTags == null) {
return Set.of();
}
Set<String> result = new LinkedHashSet<>();
for (String tag : desiredTags) {
if (tag == null || tag.isBlank()) {
continue;
}
String fqcn = categoryClassMap.get(tag);
if (fqcn == null) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("No categoryClasses mapping for tag '" + tag + "' — skipped");
}
diagnostics.println("[WARN] No categoryClasses mapping for tag '" + tag + "' — skipped");
continue;
}
result.add(fqcn);
}
return result;
}
/**
* Extracts the simple names of classes declared in {@code @Category} and
* {@code @Categories} annotations on the given method.
*
* @param method method declaration to inspect
* @return set of simple class names; may be empty but never {@code null}
*/
/* default */ static Set<String> getCategorySimpleNames(MethodDeclaration method) {
Set<String> names = new LinkedHashSet<>();
for (AnnotationExpr annotation : method.getAnnotations()) {
String name = annotation.getNameAsString();
if (ANNOTATION_CATEGORY.equals(name) || "Categories".equals(name)) {
extractCategorySimpleNames(annotation, names);
}
}
return names;
}
private static void extractCategorySimpleNames(AnnotationExpr annotation, Set<String> names) {
if (annotation.isSingleMemberAnnotationExpr()) {
extractCategoryFromExpr(annotation.asSingleMemberAnnotationExpr().getMemberValue(), names);
} else if (annotation.isNormalAnnotationExpr()) {
annotation.asNormalAnnotationExpr().getPairs().stream()
.filter(p -> "value".equals(p.getNameAsString()))
.forEach(p -> extractCategoryFromExpr(p.getValue(), names));
}
}
private static void extractCategoryFromExpr(Expression expr, Set<String> names) {
if (expr.isClassExpr()) {
String typeName = expr.asClassExpr().getType().asString();
int dot = typeName.lastIndexOf('.');
names.add(dot >= 0 ? typeName.substring(dot + 1) : typeName);
} else if (expr.isArrayInitializerExpr()) {
for (Expression element : expr.asArrayInitializerExpr().getValues()) {
extractCategoryFromExpr(element, names);
}
}
}
private static String simpleNameOf(String fqcn) {
int dot = fqcn.lastIndexOf('.');
return dot >= 0 ? fqcn.substring(dot + 1) : fqcn;
}
private static String buildFqcn(String packageName, String className) {
return packageName.isEmpty() ? className : packageName + "." + className;
}
/**
* Result of applying a desired annotation state to a single method declaration.
*
* @param tagsAdded number of tag/category annotations added
* @param tagsRemoved number of tag/category annotations removed
* @param displayNameChanged whether the {@code @DisplayName} annotation was
* set or removed (always {@code false} in JUnit 4 mode)
* @param addedCategoryFqcns fully qualified names of {@code @Category} classes
* that were written; empty for JUnit 5 patching
*/
/* default */ record MethodApplyResult(
int tagsAdded,
int tagsRemoved,
boolean displayNameChanged,
Set<String> addedCategoryFqcns) {
/**
* Compact constructor — defensive copy of the category FQCNs set.
*/
/* default */ MethodApplyResult {
addedCategoryFqcns = Set.copyOf(addedCategoryFqcns);
}
/**
* Returns {@code true} when at least one annotation was added, removed,
* or changed.
*
* @return {@code true} if the method was modified
*/
/* default */ boolean modified() {
return tagsAdded > 0 || tagsRemoved > 0 || displayNameChanged;
}
/**
* Returns {@code true} when a JUnit 5 {@code @Tag} import may be required.
*
* @return {@code true} if tags were added and no category annotation was written
*/
/* default */ boolean needsTagImport() {
return tagsAdded > 0 && addedCategoryFqcns.isEmpty();
}
/**
* Returns {@code true} when a JUnit 4 {@code @Category} import may be required.
*
* @return {@code true} if category annotations were added
*/
/* default */ boolean needsCategoryImport() {
return !addedCategoryFqcns.isEmpty();
}
/**
* Returns {@code true} when a {@code @DisplayName} import may be required.
*
* @return {@code true} if the display name was changed
*/
/* default */ boolean needsDisplayNameImport() {
return displayNameChanged;
}
}
}