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 extends ASTOperation> 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:
+ *
+ * - {@code typeReference} — produces a {@link TypeReferenceRefactor}
+ * - {@code methodInvocation} — produces a {@link MethodInvocationRefactor}
+ *
+ *
+ * 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);
+ }
+}