diff --git a/astra-cli/example-refactors.yaml b/astra-cli/example-refactors.yaml new file mode 100644 index 00000000..28639bc8 --- /dev/null +++ b/astra-cli/example-refactors.yaml @@ -0,0 +1,80 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Astra YAML refactor configuration +# +# Run with: +# astra yaml --config example-refactors.yaml \ +# --dir /path/to/source/checkout \ +# --cp /path/to/dep.jar,/path/to/other-dep.jar +# +# Supported types: typeReference, methodInvocation +# ────────────────────────────────────────────────────────────────────────────── + +refactors: + + # ── typeReference ──────────────────────────────────────────────────────────── + # Changes every reference to the old fully-qualified type to the new one, + # including imports, variable declarations, Javadoc, and qualified names. + + - type: typeReference + from: com.example.legacy.OldService + to: com.example.services.NewService + + # Inner class references: use dot notation (not dollar sign) + - type: typeReference + from: com.example.Outer.OldInner + to: com.example.NewInner + + # ── methodInvocation ───────────────────────────────────────────────────────── + # Matches method invocations and applies rename / type-change transforms. + # + # Required in 'from': qualifiedClass, method + # Optional in 'from': parameters (list of FQNs), isVarargs (boolean) + # Required in 'to': at least one of: method, qualifiedClass + + # Rename a method (keep same class) + - type: methodInvocation + from: + qualifiedClass: com.example.OldClass + method: oldMethod + to: + method: newMethod + + # Move a method to a different class (keep same name) + - type: methodInvocation + from: + qualifiedClass: com.example.OldService + method: processRequest + to: + qualifiedClass: com.example.NewService + + # Rename and move simultaneously + - type: methodInvocation + from: + qualifiedClass: com.example.OldClass + method: doSomething + to: + qualifiedClass: com.example.NewClass + method: execute + + # Match by exact parameter types to disambiguate overloads + - type: methodInvocation + from: + qualifiedClass: com.example.Processor + method: process + parameters: + - java.lang.String + - int + to: + method: handle + + # Match a varargs method specifically + - type: methodInvocation + from: + qualifiedClass: com.example.Logger + method: log + parameters: + - java.lang.String + - java.lang.Object + isVarargs: true + to: + method: logFormatted diff --git a/astra-cli/pom.xml b/astra-cli/pom.xml index 11cf5bc7..e46100c3 100644 --- a/astra-cli/pom.xml +++ b/astra-cli/pom.xml @@ -24,6 +24,12 @@ picocli 4.5.1 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.17.2 + diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraCli.java b/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraCli.java index fd87f214..1b2bb6ad 100644 --- a/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraCli.java +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraCli.java @@ -27,6 +27,7 @@ subcommands = { AstraMethodInvocation.class, AstraChangeType.class, + AstraYamlRefactor.class, CommandLine.HelpCommand.class }) public class AstraCli implements Runnable { diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraYamlRefactor.java b/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraYamlRefactor.java new file mode 100644 index 00000000..c9eb246a --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/commandline/AstraYamlRefactor.java @@ -0,0 +1,101 @@ +package org.alfasoftware.astracli.commandline; + +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.alfasoftware.astra.core.refactoring.UseCase; +import org.alfasoftware.astra.core.utils.ASTOperation; +import org.alfasoftware.astra.core.utils.AstraCore; +import org.alfasoftware.astracli.config.AstraOperationFactory; +import org.alfasoftware.astracli.config.RefactorConfig; +import org.alfasoftware.astracli.config.YamlConfigParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import picocli.CommandLine; +import picocli.CommandLine.Option; + +/** + * CLI subcommand {@code astra yaml} — applies refactoring operations declared in a YAML file. + * + *
+ * astra yaml --config refactors.yaml --dir /path/to/source --cp /path/to/dep.jar
+ * 
+ * + *

Multiple operations of different types can be combined in a single YAML file. + * See the project's {@code example-refactors.yaml} for the full schema. + */ +@CommandLine.Command(name = "yaml", + sortOptions = false, + headerHeading = "@|bold,underline Usage:|@%n%n", + synopsisHeading = "%n", + descriptionHeading = "%n@|bold,underline Description:|@%n%n", + parameterListHeading = "%n@|bold,underline Parameters:|@%n", + optionListHeading = "%n@|bold,underline Options:|@%n", + header = "Apply refactors from a YAML config file.", + description = "Reads one or more refactoring operations from a YAML configuration file and applies them to the target source directory.") +class AstraYamlRefactor implements Runnable { + + private static final Logger log = LoggerFactory.getLogger(AstraYamlRefactor.class); + + @Option( + names = {"-f", "--config"}, + required = true, + paramLabel = "", + description = "Path to the YAML refactor configuration file.") + File configFile; + + @Option( + names = {"-d", "--dir"}, + required = true, + description = "Set the path to the code checkout.") + File directory; + + @Option( + names = "--cp", + required = true, + description = "Set the path to the additional jar files. At least the jar containing the 'before' type should be specified.", + split = "[,;]") + File[] classpath; + + @Override + public void run() { + log.info("Starting [yaml] refactor from config: [" + configFile.getAbsolutePath() + "]"); + + RefactorConfig config; + try { + config = new YamlConfigParser().parse(configFile); + } catch (Exception e) { + throw new RuntimeException( + "Failed to parse YAML config file '" + configFile.getAbsolutePath() + "': " + e.getMessage(), e); + } + + List operations; + try { + operations = new AstraOperationFactory().createOperations(config); + } catch (IllegalArgumentException e) { + throw new RuntimeException("Invalid refactor configuration: " + e.getMessage(), e); + } + + log.info("Loaded " + operations.size() + " refactoring operation(s) from config"); + + AstraCore.run(directory.getAbsolutePath(), new UseCase() { + + @Override + public Set getOperations() { + return new HashSet<>(operations); + } + + @Override + public Set getAdditionalClassPathEntries() { + return Arrays.asList(classpath).stream() + .map(File::getAbsolutePath) + .collect(Collectors.toSet()); + } + }); + } +} diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/config/AstraOperationFactory.java b/astra-cli/src/main/java/org/alfasoftware/astracli/config/AstraOperationFactory.java new file mode 100644 index 00000000..90a11e27 --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/config/AstraOperationFactory.java @@ -0,0 +1,148 @@ +package org.alfasoftware.astracli.config; + +import java.util.ArrayList; +import java.util.List; + +import org.alfasoftware.astra.core.matchers.MethodMatcher; +import org.alfasoftware.astra.core.refactoring.operations.methods.MethodInvocationRefactor; +import org.alfasoftware.astra.core.refactoring.operations.types.TypeReferenceRefactor; +import org.alfasoftware.astra.core.utils.ASTOperation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Converts a {@link RefactorConfig} (parsed from YAML) into a list of {@link ASTOperation} instances + * ready to be passed to {@code AstraCore.run()}. + * + *

Supported {@code type} values: + *

+ * + *

Throws {@link IllegalArgumentException} for missing required fields or unknown types. + */ +public class AstraOperationFactory { + + static final String TYPE_METHOD_INVOCATION = "methodInvocation"; + static final String TYPE_TYPE_REFERENCE = "typeReference"; + + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * Creates one {@link ASTOperation} for each entry in the config. + * + * @throws IllegalArgumentException if the config is null/empty or any entry is invalid + */ + public List createOperations(RefactorConfig config) { + if (config == null || config.getRefactors() == null || config.getRefactors().isEmpty()) { + throw new IllegalArgumentException("Config must contain at least one refactor entry under 'refactors'"); + } + + List operations = new ArrayList<>(); + List refactors = config.getRefactors(); + for (int i = 0; i < refactors.size(); i++) { + try { + operations.add(createOperation(refactors.get(i))); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Error in refactor entry " + i + ": " + e.getMessage(), e); + } + } + return operations; + } + + private ASTOperation createOperation(RefactorEntry entry) { + if (entry.getType() == null || entry.getType().isBlank()) { + throw new IllegalArgumentException("'type' is required"); + } + + switch (entry.getType()) { + case TYPE_METHOD_INVOCATION: + return createMethodInvocationRefactor(entry); + case TYPE_TYPE_REFERENCE: + return createTypeReferenceRefactor(entry); + default: + throw new IllegalArgumentException( + "Unknown refactor type: '" + entry.getType() + "'. Must be one of: " + + TYPE_METHOD_INVOCATION + ", " + TYPE_TYPE_REFERENCE); + } + } + + private MethodInvocationRefactor createMethodInvocationRefactor(RefactorEntry entry) { + JsonNode fromNode = entry.getFrom(); + if (fromNode == null || !fromNode.isObject()) { + throw new IllegalArgumentException( + "'from' must be a YAML mapping (object) for type '" + TYPE_METHOD_INVOCATION + "'"); + } + + MethodFromConfig fromConfig = mapper.convertValue(fromNode, MethodFromConfig.class); + + if (isNullOrBlank(fromConfig.getQualifiedClass())) { + throw new IllegalArgumentException("'from.qualifiedClass' is required for type '" + TYPE_METHOD_INVOCATION + "'"); + } + if (isNullOrBlank(fromConfig.getMethod())) { + throw new IllegalArgumentException("'from.method' is required for type '" + TYPE_METHOD_INVOCATION + "'"); + } + + MethodMatcher.Builder matcherBuilder = MethodMatcher.builder() + .withFullyQualifiedDeclaringType(fromConfig.getQualifiedClass()) + .withMethodName(fromConfig.getMethod()); + + if (fromConfig.getParameters() != null) { + matcherBuilder = matcherBuilder.withFullyQualifiedParameters(fromConfig.getParameters()); + } + + if (Boolean.TRUE.equals(fromConfig.getVarargs())) { + matcherBuilder = matcherBuilder.isVarargs(true); + } else if (Boolean.FALSE.equals(fromConfig.getVarargs())) { + matcherBuilder = matcherBuilder.isVarargs(false); + } + + JsonNode toNode = entry.getTo(); + if (toNode == null || !toNode.isObject()) { + throw new IllegalArgumentException( + "'to' must be a YAML mapping (object) for type '" + TYPE_METHOD_INVOCATION + "'"); + } + + MethodToConfig toConfig = mapper.convertValue(toNode, MethodToConfig.class); + + if (isNullOrBlank(toConfig.getMethod()) && isNullOrBlank(toConfig.getQualifiedClass())) { + throw new IllegalArgumentException( + "At least one of 'to.method' or 'to.qualifiedClass' must be specified for type '" + TYPE_METHOD_INVOCATION + "'"); + } + + MethodInvocationRefactor.Changes changes = new MethodInvocationRefactor.Changes(); + if (!isNullOrBlank(toConfig.getMethod())) { + changes = changes.toNewMethodName(toConfig.getMethod()); + } + if (!isNullOrBlank(toConfig.getQualifiedClass())) { + changes = changes.toNewType(toConfig.getQualifiedClass()); + } + + return MethodInvocationRefactor.from(matcherBuilder.build()).to(changes); + } + + private TypeReferenceRefactor createTypeReferenceRefactor(RefactorEntry entry) { + JsonNode fromNode = entry.getFrom(); + JsonNode toNode = entry.getTo(); + + if (fromNode == null || !fromNode.isTextual() || fromNode.asText().isBlank()) { + throw new IllegalArgumentException( + "'from' must be a non-empty string (fully qualified type name) for type '" + TYPE_TYPE_REFERENCE + "'"); + } + if (toNode == null || !toNode.isTextual() || toNode.asText().isBlank()) { + throw new IllegalArgumentException( + "'to' must be a non-empty string (fully qualified type name) for type '" + TYPE_TYPE_REFERENCE + "'"); + } + + return TypeReferenceRefactor.builder() + .fromType(fromNode.asText()) + .toType(toNode.asText()) + .build(); + } + + private static boolean isNullOrBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/config/MethodFromConfig.java b/astra-cli/src/main/java/org/alfasoftware/astracli/config/MethodFromConfig.java new file mode 100644 index 00000000..a4d135ce --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/config/MethodFromConfig.java @@ -0,0 +1,67 @@ +package org.alfasoftware.astracli.config; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@code from} block for a {@code methodInvocation} refactor entry, + * describing the method invocation to match. + * + *

+ * from:
+ *   qualifiedClass: com.example.OldClass
+ *   method: oldMethod
+ *   parameters:         # optional; omit to match any parameter list
+ *     - java.lang.String
+ *     - int
+ *   isVarargs: false    # optional; omit to match regardless of varargs
+ * 
+ */ +public class MethodFromConfig { + + @JsonProperty("qualifiedClass") + private String qualifiedClass; + + @JsonProperty("method") + private String method; + + @JsonProperty("parameters") + private List parameters; + + /** When {@code null} the varargs constraint is not applied to the match. */ + @JsonProperty("isVarargs") + private Boolean varargs; + + public String getQualifiedClass() { + return qualifiedClass; + } + + public void setQualifiedClass(String qualifiedClass) { + this.qualifiedClass = qualifiedClass; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public List getParameters() { + return parameters; + } + + public void setParameters(List parameters) { + this.parameters = parameters; + } + + public Boolean getVarargs() { + return varargs; + } + + public void setVarargs(Boolean varargs) { + this.varargs = varargs; + } +} diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/config/MethodToConfig.java b/astra-cli/src/main/java/org/alfasoftware/astracli/config/MethodToConfig.java new file mode 100644 index 00000000..65bb2a46 --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/config/MethodToConfig.java @@ -0,0 +1,38 @@ +package org.alfasoftware.astracli.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@code to} block for a {@code methodInvocation} refactor entry, + * describing the desired state after the refactor. At least one field must be set. + * + *
+ * to:
+ *   qualifiedClass: com.example.NewClass  # optional — changes the owning type
+ *   method: newMethod                      # optional — renames the method
+ * 
+ */ +public class MethodToConfig { + + @JsonProperty("qualifiedClass") + private String qualifiedClass; + + @JsonProperty("method") + private String method; + + public String getQualifiedClass() { + return qualifiedClass; + } + + public void setQualifiedClass(String qualifiedClass) { + this.qualifiedClass = qualifiedClass; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } +} diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/config/RefactorConfig.java b/astra-cli/src/main/java/org/alfasoftware/astracli/config/RefactorConfig.java new file mode 100644 index 00000000..e20becaf --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/config/RefactorConfig.java @@ -0,0 +1,35 @@ +package org.alfasoftware.astracli.config; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Top-level model for an astra YAML refactor configuration file. + * + *
+ * refactors:
+ *   - type: typeReference
+ *     from: com.example.OldType
+ *     to:   com.example.NewType
+ *   - type: methodInvocation
+ *     from:
+ *       qualifiedClass: com.example.OldClass
+ *       method: oldMethod
+ *     to:
+ *       method: newMethod
+ * 
+ */ +public class RefactorConfig { + + @JsonProperty("refactors") + private List refactors; + + public List getRefactors() { + return refactors; + } + + public void setRefactors(List refactors) { + this.refactors = refactors; + } +} diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/config/RefactorEntry.java b/astra-cli/src/main/java/org/alfasoftware/astracli/config/RefactorEntry.java new file mode 100644 index 00000000..6d00ed3e --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/config/RefactorEntry.java @@ -0,0 +1,57 @@ +package org.alfasoftware.astracli.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A single refactoring operation entry within a {@link RefactorConfig}. + * + *

The {@code type} discriminator controls how {@code from} and {@code to} are interpreted: + *

    + *
  • {@code typeReference} — {@code from} and {@code to} are plain strings (fully qualified type names)
  • + *
  • {@code methodInvocation} — {@code from} and {@code to} are objects (see {@link MethodFromConfig} / {@link MethodToConfig})
  • + *
+ */ +public class RefactorEntry { + + @JsonProperty("type") + private String type; + + /** + * For {@code typeReference}: a plain string FQN. + * For {@code methodInvocation}: an object with {@code qualifiedClass}, {@code method}, etc. + */ + @JsonProperty("from") + private JsonNode from; + + /** + * For {@code typeReference}: a plain string FQN. + * For {@code methodInvocation}: an object with optional {@code qualifiedClass} and/or {@code method}. + */ + @JsonProperty("to") + private JsonNode to; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public JsonNode getFrom() { + return from; + } + + public void setFrom(JsonNode from) { + this.from = from; + } + + public JsonNode getTo() { + return to; + } + + public void setTo(JsonNode to) { + this.to = to; + } +} diff --git a/astra-cli/src/main/java/org/alfasoftware/astracli/config/YamlConfigParser.java b/astra-cli/src/main/java/org/alfasoftware/astracli/config/YamlConfigParser.java new file mode 100644 index 00000000..1c29ea92 --- /dev/null +++ b/astra-cli/src/main/java/org/alfasoftware/astracli/config/YamlConfigParser.java @@ -0,0 +1,35 @@ +package org.alfasoftware.astracli.config; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +/** + * Parses an astra YAML refactor configuration file into a {@link RefactorConfig}. + * + *

Accepts a {@link File}, a raw YAML {@link String}, or an {@link InputStream}. + * Throws {@link IOException} on malformed YAML or if the file cannot be read. + */ +public class YamlConfigParser { + + private final ObjectMapper mapper; + + public YamlConfigParser() { + this.mapper = new ObjectMapper(new YAMLFactory()); + } + + public RefactorConfig parse(File configFile) throws IOException { + return mapper.readValue(configFile, RefactorConfig.class); + } + + public RefactorConfig parse(String yaml) throws IOException { + return mapper.readValue(yaml, RefactorConfig.class); + } + + public RefactorConfig parse(InputStream stream) throws IOException { + return mapper.readValue(stream, RefactorConfig.class); + } +} diff --git a/astra-cli/src/test/java/org/alfasoftware/astracli/config/TestAstraOperationFactory.java b/astra-cli/src/test/java/org/alfasoftware/astracli/config/TestAstraOperationFactory.java new file mode 100644 index 00000000..8cb54f36 --- /dev/null +++ b/astra-cli/src/test/java/org/alfasoftware/astracli/config/TestAstraOperationFactory.java @@ -0,0 +1,310 @@ +package org.alfasoftware.astracli.config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import org.alfasoftware.astra.core.refactoring.operations.methods.MethodInvocationRefactor; +import org.alfasoftware.astra.core.refactoring.operations.types.TypeReferenceRefactor; +import org.alfasoftware.astra.core.utils.ASTOperation; +import org.junit.Test; + +public class TestAstraOperationFactory { + + private final YamlConfigParser parser = new YamlConfigParser(); + private final AstraOperationFactory factory = new AstraOperationFactory(); + + // ─── typeReference ───────────────────────────────────────────────────────── + + @Test + public void typeReferenceYamlProducesTypeReferenceRefactor() throws IOException { + String yaml = + "refactors:\n" + + " - type: typeReference\n" + + " from: com.example.OldType\n" + + " to: com.example.NewType\n"; + + List ops = factory.createOperations(parser.parse(yaml)); + + assertEquals(1, ops.size()); + assertTrue(ops.get(0) instanceof TypeReferenceRefactor); + + TypeReferenceRefactor refactor = (TypeReferenceRefactor) ops.get(0); + assertEquals("com.example.OldType", refactor.getFromType()); + } + + // ─── methodInvocation (rename only) ─────────────────────────────────────── + + @Test + public void methodInvocationRenameOnlyProducesCorrectMatcher() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.OldClass\n" + + " method: oldMethod\n" + + " to:\n" + + " method: newMethod\n"; + + List ops = factory.createOperations(parser.parse(yaml)); + + assertEquals(1, ops.size()); + assertTrue(ops.get(0) instanceof MethodInvocationRefactor); + + MethodInvocationRefactor refactor = (MethodInvocationRefactor) ops.get(0); + assertEquals(Optional.of("com.example.OldClass"), + refactor.getBeforeMatcher().getFullyQualifiedDeclaringTypeExactName()); + assertEquals(Optional.of("oldMethod"), + refactor.getBeforeMatcher().getMethodNameExactName()); + assertTrue("No parameters expected", refactor.getBeforeMatcher().getFullyQualifiedParameterNames().isEmpty()); + } + + // ─── methodInvocation (with parameters) ─────────────────────────────────── + + @Test + public void methodInvocationWithParametersParsesParameterList() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.Svc\n" + + " method: process\n" + + " parameters:\n" + + " - java.lang.String\n" + + " - int\n" + + " to:\n" + + " method: handle\n"; + + List ops = factory.createOperations(parser.parse(yaml)); + + MethodInvocationRefactor refactor = (MethodInvocationRefactor) ops.get(0); + Optional> params = refactor.getBeforeMatcher().getFullyQualifiedParameterNames(); + assertTrue(params.isPresent()); + assertEquals(List.of("java.lang.String", "int"), params.get()); + } + + // ─── methodInvocation (change type) ─────────────────────────────────────── + + @Test + public void methodInvocationChangeTypeProducesOperation() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.OldSvc\n" + + " method: doWork\n" + + " to:\n" + + " qualifiedClass: com.example.NewSvc\n"; + + List ops = factory.createOperations(parser.parse(yaml)); + + assertEquals(1, ops.size()); + assertTrue(ops.get(0) instanceof MethodInvocationRefactor); + } + + // ─── multiple operations ─────────────────────────────────────────────────── + + @Test + public void multipleEntriesProduceMultipleOperations() throws IOException { + String yaml = + "refactors:\n" + + " - type: typeReference\n" + + " from: com.example.A\n" + + " to: com.example.B\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.C\n" + + " method: foo\n" + + " to:\n" + + " method: bar\n" + + " - type: typeReference\n" + + " from: com.example.X\n" + + " to: com.example.Y\n"; + + List ops = factory.createOperations(parser.parse(yaml)); + + assertEquals(3, ops.size()); + assertTrue(ops.get(0) instanceof TypeReferenceRefactor); + assertTrue(ops.get(1) instanceof MethodInvocationRefactor); + assertTrue(ops.get(2) instanceof TypeReferenceRefactor); + } + + // ─── error: null / empty config ─────────────────────────────────────────── + + @Test(expected = IllegalArgumentException.class) + public void nullConfigThrowsIllegalArgumentException() { + factory.createOperations(null); + } + + @Test(expected = IllegalArgumentException.class) + public void emptyRefactorsListThrowsIllegalArgumentException() throws IOException { + factory.createOperations(parser.parse("refactors: []\n")); + } + + // ─── error: unknown type ─────────────────────────────────────────────────── + + @Test + public void unknownTypeThrowsIllegalArgumentExceptionWithUsefulMessage() throws IOException { + String yaml = + "refactors:\n" + + " - type: unknownOperation\n" + + " from: com.example.A\n" + + " to: com.example.B\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue("Message should mention the unknown type", + e.getMessage().contains("unknownOperation")); + assertTrue("Message should mention valid types", + e.getMessage().contains(AstraOperationFactory.TYPE_METHOD_INVOCATION)); + } + } + + // ─── error: missing type field ──────────────────────────────────────────── + + @Test + public void missingTypeFieldThrowsIllegalArgumentException() throws IOException { + String yaml = + "refactors:\n" + + " - from: com.example.OldType\n" + + " to: com.example.NewType\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("'type' is required")); + } + } + + // ─── error: methodInvocation with string 'from' ─────────────────────────── + + @Test + public void methodInvocationWithStringFromThrowsIllegalArgumentException() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from: com.example.OldClass\n" + + " to:\n" + + " method: newMethod\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue("Message should mention that 'from' must be an object", + e.getMessage().contains("mapping") || e.getMessage().contains("object")); + } + } + + // ─── error: methodInvocation missing qualifiedClass ─────────────────────── + + @Test + public void methodInvocationMissingQualifiedClassThrowsIllegalArgumentException() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " method: oldMethod\n" + + " to:\n" + + " method: newMethod\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("qualifiedClass")); + } + } + + // ─── error: methodInvocation missing method name ────────────────────────── + + @Test + public void methodInvocationMissingMethodNameThrowsIllegalArgumentException() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.OldClass\n" + + " to:\n" + + " method: newMethod\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("from.method")); + } + } + + // ─── error: methodInvocation with empty 'to' ────────────────────────────── + + @Test + public void methodInvocationWithEmptyToThrowsIllegalArgumentException() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.OldClass\n" + + " method: oldMethod\n" + + " to: {}\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue("Message should mention 'to.method' or 'to.qualifiedClass'", + e.getMessage().contains("to.method") || e.getMessage().contains("to.qualifiedClass")); + } + } + + // ─── error: typeReference with object 'from' ────────────────────────────── + + @Test + public void typeReferenceWithObjectFromThrowsIllegalArgumentException() throws IOException { + String yaml = + "refactors:\n" + + " - type: typeReference\n" + + " from:\n" + + " qualifiedClass: com.example.OldClass\n" + + " to: com.example.NewType\n"; + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue("Message should mention 'from' must be a string", + e.getMessage().contains("string") || e.getMessage().contains("non-empty")); + } + } + + // ─── error message includes entry index ─────────────────────────────────── + + @Test + public void errorMessageIncludesEntryIndex() throws IOException { + String yaml = + "refactors:\n" + + " - type: typeReference\n" + + " from: com.example.OldType\n" + + " to: com.example.NewType\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.Svc\n" + + " method: doIt\n" + + " to: {}\n"; // invalid: empty to + + try { + factory.createOperations(parser.parse(yaml)); + org.junit.Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue("Error message should mention entry index 1", + e.getMessage().contains("entry 1")); + } + } +} diff --git a/astra-cli/src/test/java/org/alfasoftware/astracli/config/TestYamlConfigParser.java b/astra-cli/src/test/java/org/alfasoftware/astracli/config/TestYamlConfigParser.java new file mode 100644 index 00000000..e3951f02 --- /dev/null +++ b/astra-cli/src/test/java/org/alfasoftware/astracli/config/TestYamlConfigParser.java @@ -0,0 +1,173 @@ +package org.alfasoftware.astracli.config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.List; + +import org.junit.Test; + +import com.fasterxml.jackson.core.JsonParseException; + +public class TestYamlConfigParser { + + private final YamlConfigParser parser = new YamlConfigParser(); + + // ─── typeReference ───────────────────────────────────────────────────────── + + @Test + public void typeReferenceEntryParsesFromAndToAsStrings() throws IOException { + String yaml = + "refactors:\n" + + " - type: typeReference\n" + + " from: com.example.OldType\n" + + " to: com.example.NewType\n"; + + RefactorConfig config = parser.parse(yaml); + + assertNotNull(config.getRefactors()); + assertEquals(1, config.getRefactors().size()); + + RefactorEntry entry = config.getRefactors().get(0); + assertEquals("typeReference", entry.getType()); + assertTrue("'from' should be a text node", entry.getFrom().isTextual()); + assertEquals("com.example.OldType", entry.getFrom().asText()); + assertTrue("'to' should be a text node", entry.getTo().isTextual()); + assertEquals("com.example.NewType", entry.getTo().asText()); + } + + // ─── methodInvocation (minimal) ──────────────────────────────────────────── + + @Test + public void methodInvocationEntryParsesFromAndToAsObjects() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.OldClass\n" + + " method: oldMethod\n" + + " to:\n" + + " method: newMethod\n"; + + RefactorConfig config = parser.parse(yaml); + + RefactorEntry entry = config.getRefactors().get(0); + assertEquals("methodInvocation", entry.getType()); + assertTrue("'from' should be an object node", entry.getFrom().isObject()); + assertTrue("'to' should be an object node", entry.getTo().isObject()); + + MethodFromConfig from = toMethodFrom(entry); + assertEquals("com.example.OldClass", from.getQualifiedClass()); + assertEquals("oldMethod", from.getMethod()); + assertNull(from.getParameters()); + assertNull(from.getVarargs()); + + MethodToConfig to = toMethodTo(entry); + assertEquals("newMethod", to.getMethod()); + assertNull(to.getQualifiedClass()); + } + + // ─── methodInvocation (with parameters and varargs) ─────────────────────── + + @Test + public void methodInvocationWithParametersAndVarargsParsesCorrectly() throws IOException { + String yaml = + "refactors:\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.OldClass\n" + + " method: oldMethod\n" + + " parameters:\n" + + " - java.lang.String\n" + + " - int\n" + + " isVarargs: true\n" + + " to:\n" + + " qualifiedClass: com.example.NewClass\n" + + " method: newMethod\n"; + + RefactorConfig config = parser.parse(yaml); + MethodFromConfig from = toMethodFrom(config.getRefactors().get(0)); + + List params = from.getParameters(); + assertNotNull(params); + assertEquals(2, params.size()); + assertEquals("java.lang.String", params.get(0)); + assertEquals("int", params.get(1)); + assertEquals(Boolean.TRUE, from.getVarargs()); + + MethodToConfig to = toMethodTo(config.getRefactors().get(0)); + assertEquals("com.example.NewClass", to.getQualifiedClass()); + assertEquals("newMethod", to.getMethod()); + } + + // ─── multiple entries ────────────────────────────────────────────────────── + + @Test + public void multipleEntriesOfMixedTypesParse() throws IOException { + String yaml = + "refactors:\n" + + " - type: typeReference\n" + + " from: com.example.OldType\n" + + " to: com.example.NewType\n" + + " - type: methodInvocation\n" + + " from:\n" + + " qualifiedClass: com.example.Svc\n" + + " method: doIt\n" + + " to:\n" + + " method: doItBetter\n" + + " - type: typeReference\n" + + " from: com.example.AnotherOld\n" + + " to: com.example.AnotherNew\n"; + + RefactorConfig config = parser.parse(yaml); + + assertEquals(3, config.getRefactors().size()); + assertEquals("typeReference", config.getRefactors().get(0).getType()); + assertEquals("methodInvocation", config.getRefactors().get(1).getType()); + assertEquals("typeReference", config.getRefactors().get(2).getType()); + } + + // ─── empty / null refactors list ────────────────────────────────────────── + + @Test + public void emptyRefactorsListParsesToEmptyList() throws IOException { + String yaml = "refactors: []\n"; + + RefactorConfig config = parser.parse(yaml); + + assertNotNull(config.getRefactors()); + assertTrue(config.getRefactors().isEmpty()); + } + + @Test + public void missingRefactorsKeyParsesToNullList() throws IOException { + String yaml = "{}"; + + RefactorConfig config = parser.parse(yaml); + + assertNull(config.getRefactors()); + } + + // ─── invalid YAML ────────────────────────────────────────────────────────── + + @Test(expected = IOException.class) + public void malformedYamlThrowsIOException() throws IOException { + // Tabs in YAML are illegal + parser.parse("refactors:\n\t- type: typeReference\n"); + } + + // ─── helpers ─────────────────────────────────────────────────────────────── + + private MethodFromConfig toMethodFrom(RefactorEntry entry) { + return new com.fasterxml.jackson.databind.ObjectMapper() + .convertValue(entry.getFrom(), MethodFromConfig.class); + } + + private MethodToConfig toMethodTo(RefactorEntry entry) { + return new com.fasterxml.jackson.databind.ObjectMapper() + .convertValue(entry.getTo(), MethodToConfig.class); + } +}