diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java index 890109b..fde211a 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/DataVersion.java @@ -27,6 +27,7 @@ import de.splatgames.aether.datafixers.api.fix.DataFixer; import de.splatgames.aether.datafixers.api.schema.Schema; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Objects; @@ -171,7 +172,7 @@ public int compareTo(@NotNull final DataVersion o) { * @see #hashCode() */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -222,6 +223,7 @@ public int hashCode() { * @return a string representation of this data version in the format {@code "DataVersion{version=N}"} */ @Override + @NotNull public String toString() { return "DataVersion{" + "version=" + this.version + '}'; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java index 5aaedb9..37d1f52 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/TypeReference.java @@ -28,6 +28,7 @@ import de.splatgames.aether.datafixers.api.type.Type; import de.splatgames.aether.datafixers.api.type.TypeRegistry; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * A unique identifier for a data type in the data fixing system. @@ -183,7 +184,7 @@ public int hashCode() { * @see #hashCode() */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -217,6 +218,7 @@ public boolean equals(final Object obj) { * @see #getId() */ @Override + @NotNull public String toString() { return "TypeReference{" + "id='" + this.id + '\'' + '}'; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java index 444fa89..f7a2d1b 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java @@ -53,6 +53,10 @@ * @param captureRuleDetails whether to capture individual rule application details * @param maxSnapshotLength maximum length for snapshot strings (0 for unlimited) * @param prettyPrintSnapshots whether to format snapshots for readability + * @param captureFieldDetails whether to capture field-level operation metadata from + * {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} + * implementations; requires {@code captureRuleDetails} to be + * {@code true} to have any effect (since 1.0.0) * @author Erik Pförtner * @see DiagnosticContext * @see MigrationReport @@ -62,7 +66,8 @@ public record DiagnosticOptions( boolean captureSnapshots, boolean captureRuleDetails, int maxSnapshotLength, - boolean prettyPrintSnapshots + boolean prettyPrintSnapshots, + boolean captureFieldDetails ) { /** @@ -79,13 +84,14 @@ public record DiagnosticOptions( *
  • {@code captureRuleDetails} = {@code true}
  • *
  • {@code maxSnapshotLength} = {@code 10000}
  • *
  • {@code prettyPrintSnapshots} = {@code true}
  • + *
  • {@code captureFieldDetails} = {@code true}
  • * * * @return default diagnostic options */ @NotNull public static DiagnosticOptions defaults() { - return new DiagnosticOptions(true, true, DEFAULT_MAX_SNAPSHOT_LENGTH, true); + return new DiagnosticOptions(true, true, DEFAULT_MAX_SNAPSHOT_LENGTH, true, true); } /** @@ -97,13 +103,14 @@ public static DiagnosticOptions defaults() { *
  • {@code captureRuleDetails} = {@code false}
  • *
  • {@code maxSnapshotLength} = {@code 0}
  • *
  • {@code prettyPrintSnapshots} = {@code false}
  • + *
  • {@code captureFieldDetails} = {@code false}
  • * * * @return minimal diagnostic options */ @NotNull public static DiagnosticOptions minimal() { - return new DiagnosticOptions(false, false, 0, false); + return new DiagnosticOptions(false, false, 0, false, false); } /** @@ -127,6 +134,7 @@ public static final class Builder { private boolean captureRuleDetails = true; private int maxSnapshotLength = DEFAULT_MAX_SNAPSHOT_LENGTH; private boolean prettyPrintSnapshots = true; + private boolean captureFieldDetails = true; private Builder() { } @@ -197,6 +205,24 @@ public Builder prettyPrintSnapshots(final boolean prettyPrintSnapshots) { return this; } + /** + * Sets whether to capture field-level operation metadata. + * + *

    When enabled and {@code captureRuleDetails} is also enabled, the migration + * report will include structured metadata about which fields each rule affects. + * This information is extracted from rules that implement + * {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule}.

    + * + * @param captureFieldDetails {@code true} to capture field-level details + * @return this builder + * @since 1.0.0 + */ + @NotNull + public Builder captureFieldDetails(final boolean captureFieldDetails) { + this.captureFieldDetails = captureFieldDetails; + return this; + } + /** * Builds the diagnostic options. * @@ -208,7 +234,8 @@ public DiagnosticOptions build() { this.captureSnapshots, this.captureRuleDetails, this.maxSnapshotLength, - this.prettyPrintSnapshots + this.prettyPrintSnapshots, + this.captureFieldDetails ); } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java new file mode 100644 index 0000000..267cba0 --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.diagnostic; + +import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule; +import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Metadata about a single field-level operation within a + * {@link TypeRewriteRule} application. + * + *

    {@code FieldOperation} captures structured information about which fields + * are affected by a rule and how. This enables field-level diagnostic reporting beyond the type-level granularity of + * {@link RuleApplication}.

    + * + *

    Usage Example

    + *
    {@code
    + * MigrationReport report = context.getReport();
    + * for (FixExecution fix : report.fixExecutions()) {
    + *     for (RuleApplication rule : fix.ruleApplications()) {
    + *         for (FieldOperation fieldOp : rule.fieldOperations()) {
    + *             System.out.println(fieldOp.operationType() + " on " +
    + *                 String.join(".", fieldOp.fieldPath()));
    + *         }
    + *     }
    + * }
    + * }
    + * + *

    Path Representation

    + *

    Field paths are represented as a list of path segments. A top-level field + * {@code "name"} is represented as {@code ["name"]}, while a nested field {@code "position.x"} is represented as + * {@code ["position", "x"]}.

    + * + * @param operationType the kind of field operation, must not be {@code null} + * @param fieldPath the path segments to the affected field, must not be {@code null} or empty + * @param targetFieldName the target field name for operations that have one (e.g., rename target, move/copy destination + * as dot-notation path); {@code null} for operations without a target + * @param description optional human-readable description providing additional context; may be {@code null} + * @author Erik Pförtner + * @see FieldOperationType + * @see RuleApplication#fieldOperations() + * @see FieldAwareRule + * @since 1.0.0 + */ +public record FieldOperation( + @NotNull FieldOperationType operationType, + @NotNull List fieldPath, + @Nullable String targetFieldName, + @Nullable String description +) { + + /** + * Creates a new field operation record. + * + * @param operationType the kind of field operation, must not be {@code null} + * @param fieldPath the path segments to the affected field, must not be {@code null} or empty + * @param targetFieldName the target field name (may be {@code null}) + * @param description optional description (may be {@code null}) + * @throws NullPointerException if {@code operationType} or {@code fieldPath} is {@code null} + * @throws IllegalArgumentException if {@code fieldPath} is empty + */ + public FieldOperation { + Preconditions.checkNotNull(operationType, "operationType must not be null"); + Preconditions.checkNotNull(fieldPath, "fieldPath must not be null"); + Preconditions.checkArgument(!fieldPath.isEmpty(), "fieldPath must not be empty"); + fieldPath = List.copyOf(fieldPath); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Top-Level Fields + // ------------------------------------------------------------------------- + + /** + * Creates a rename field operation. + * + * @param oldName the current field name, must not be {@code null} + * @param newName the new field name, must not be {@code null} + * @return a new rename field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation rename(@NotNull final String oldName, + @NotNull final String newName) { + Preconditions.checkNotNull(oldName, "oldName must not be null"); + Preconditions.checkNotNull(newName, "newName must not be null"); + return new FieldOperation(FieldOperationType.RENAME, List.of(oldName), newName, null); + } + + /** + * Creates a remove field operation. + * + * @param fieldName the field name to remove, must not be {@code null} + * @return a new remove field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation remove(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.REMOVE, List.of(fieldName), null, null); + } + + /** + * Creates an add field operation. + * + * @param fieldName the field name to add, must not be {@code null} + * @return a new add field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation add(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.ADD, List.of(fieldName), null, null); + } + + /** + * Creates a transform field operation. + * + * @param fieldName the field name to transform, must not be {@code null} + * @return a new transform field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation transform(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.TRANSFORM, List.of(fieldName), null, null); + } + + /** + * Creates a set field operation. + * + * @param fieldName the field name to set, must not be {@code null} + * @return a new set field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation set(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.SET, List.of(fieldName), null, null); + } + + /** + * Creates a move field operation. + * + * @param sourcePath the source field path in dot-notation, must not be {@code null} + * @param targetPath the target field path in dot-notation, must not be {@code null} + * @return a new move field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation move(@NotNull final String sourcePath, + @NotNull final String targetPath) { + Preconditions.checkNotNull(sourcePath, "sourcePath must not be null"); + Preconditions.checkNotNull(targetPath, "targetPath must not be null"); + return new FieldOperation(FieldOperationType.MOVE, parsePath(sourcePath), targetPath, null); + } + + /** + * Creates a copy field operation. + * + * @param sourcePath the source field path in dot-notation, must not be {@code null} + * @param targetPath the target field path in dot-notation, must not be {@code null} + * @return a new copy field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation copy(@NotNull final String sourcePath, + @NotNull final String targetPath) { + Preconditions.checkNotNull(sourcePath, "sourcePath must not be null"); + Preconditions.checkNotNull(targetPath, "targetPath must not be null"); + return new FieldOperation(FieldOperationType.COPY, parsePath(sourcePath), targetPath, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Nested / Path-Based Fields + // ------------------------------------------------------------------------- + + /** + * Creates a rename operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field (e.g., {@code "position.posX"}), must not be {@code null} + * @param newName the new name for the leaf field, must not be {@code null} + * @return a new rename field operation with a nested path + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation renamePath(@NotNull final String path, + @NotNull final String newName) { + Preconditions.checkNotNull(path, "path must not be null"); + Preconditions.checkNotNull(newName, "newName must not be null"); + return new FieldOperation(FieldOperationType.RENAME, parsePath(path), newName, null); + } + + /** + * Creates a remove operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field, must not be {@code null} + * @return a new remove field operation with a nested path + * @throws NullPointerException if {@code path} is {@code null} + */ + @NotNull + public static FieldOperation removePath(@NotNull final String path) { + Preconditions.checkNotNull(path, "path must not be null"); + return new FieldOperation(FieldOperationType.REMOVE, parsePath(path), null, null); + } + + /** + * Creates an add operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field, must not be {@code null} + * @return a new add field operation with a nested path + * @throws NullPointerException if {@code path} is {@code null} + */ + @NotNull + public static FieldOperation addPath(@NotNull final String path) { + Preconditions.checkNotNull(path, "path must not be null"); + return new FieldOperation(FieldOperationType.ADD, parsePath(path), null, null); + } + + /** + * Creates a transform operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field, must not be {@code null} + * @return a new transform field operation with a nested path + * @throws NullPointerException if {@code path} is {@code null} + */ + @NotNull + public static FieldOperation transformPath(@NotNull final String path) { + Preconditions.checkNotNull(path, "path must not be null"); + return new FieldOperation(FieldOperationType.TRANSFORM, parsePath(path), null, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Structural Operations + // ------------------------------------------------------------------------- + + /** + * Creates a group fields operation. + * + *

    This represents grouping multiple source fields into a nested object + * at the target field name.

    + * + * @param targetField the name of the new nested object field, must not be {@code null} + * @param sourceFields the fields to group into the target, must not be {@code null} or empty + * @return a new group field operation + * @throws NullPointerException if any argument is {@code null} + * @throws IllegalArgumentException if {@code sourceFields} is empty + */ + @NotNull + public static FieldOperation group(@NotNull final String targetField, + @NotNull final String... sourceFields) { + Preconditions.checkNotNull(targetField, "targetField must not be null"); + Preconditions.checkNotNull(sourceFields, "sourceFields must not be null"); + Preconditions.checkArgument(sourceFields.length > 0, "sourceFields must not be empty"); + return new FieldOperation( + FieldOperationType.GROUP, + List.copyOf(Arrays.asList(sourceFields)), + targetField, + null + ); + } + + /** + * Creates a flatten field operation. + * + * @param fieldName the name of the nested object to flatten, must not be {@code null} + * @return a new flatten field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation flatten(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.FLATTEN, List.of(fieldName), null, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Conditional Operations + // ------------------------------------------------------------------------- + + /** + * Creates a conditional field operation. + * + * @param fieldName the field name that the condition checks, must not be {@code null} + * @param conditionType a description of the condition (e.g., {@code "exists"}, {@code "missing"}, + * {@code "equals"}), must not be {@code null} + * @return a new conditional field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation conditional(@NotNull final String fieldName, + @NotNull final String conditionType) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + Preconditions.checkNotNull(conditionType, "conditionType must not be null"); + return new FieldOperation( + FieldOperationType.CONDITIONAL, + List.of(fieldName), + null, + conditionType + ); + } + + // ------------------------------------------------------------------------- + // Convenience Methods + // ------------------------------------------------------------------------- + + /** + * Parses a dot-notation path string into a list of path segments. + * + * @param path the dot-notation path (e.g., {@code "position.x"}) + * @return list of path segments (e.g., {@code ["position", "x"]}) + */ + @NotNull + private static List parsePath(@NotNull final String path) { + return List.of(path.split("\\.")); + } + + /** + * Returns the target field name as an {@link Optional}. + * + * @return optional containing the target field name, or empty if none + */ + @NotNull + public Optional targetFieldNameOpt() { + return Optional.ofNullable(this.targetFieldName); + } + + /** + * Returns the description as an {@link Optional}. + * + * @return optional containing the description, or empty if none + */ + @NotNull + public Optional descriptionOpt() { + return Optional.ofNullable(this.description); + } + + /** + * Returns the field path as a dot-notation string. + * + *

    For example, a path of {@code ["position", "x"]} returns {@code "position.x"}.

    + * + * @return the dot-notation field path, never {@code null} + */ + @NotNull + public String fieldPathString() { + return String.join(".", this.fieldPath); + } + + /** + * Returns whether this operation targets a nested field (path depth > 1). + * + * @return {@code true} if the field path has more than one segment + */ + public boolean isNested() { + return this.fieldPath.size() > 1; + } + + // ------------------------------------------------------------------------- + // Internal Helpers + // ------------------------------------------------------------------------- + + /** + * Returns a human-readable summary of this field operation. + * + * @return formatted summary string, never {@code null} + */ + @NotNull + public String toSummary() { + final StringBuilder sb = new StringBuilder(); + sb.append(this.operationType.name()).append('(').append(fieldPathString()); + if (this.targetFieldName != null) { + sb.append(" -> ").append(this.targetFieldName); + } + sb.append(')'); + if (this.description != null) { + sb.append(" [").append(this.description).append(']'); + } + return sb.toString(); + } +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java new file mode 100644 index 0000000..ecd7f5a --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.diagnostic; + +import de.splatgames.aether.datafixers.api.rewrite.Rules; +import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; +import org.jetbrains.annotations.NotNull; + +/** + * Classifies the kind of field-level operation performed by a + * {@link TypeRewriteRule} during a migration. + * + *

    Each constant corresponds to one or more factory methods in + * {@link Rules}:

    + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Mapping of field operation types to Rules methods
    TypeRules method(s)
    {@link #RENAME}{@code renameField}, {@code renameFields}, {@code renameFieldAt}
    {@link #REMOVE}{@code removeField}, {@code removeFields}, {@code removeFieldAt}
    {@link #ADD}{@code addField}, {@code addFieldAt}
    {@link #TRANSFORM}{@code transformField}, {@code transformFieldAt}
    {@link #SET}{@code setField}
    {@link #MOVE}{@code moveField}
    {@link #COPY}{@code copyField}
    {@link #GROUP}{@code groupFields}
    {@link #FLATTEN}{@code flattenField}
    {@link #CONDITIONAL}{@code ifFieldExists}, {@code ifFieldMissing}, {@code ifFieldEquals}
    + * + * @author Erik Pförtner + * @see FieldOperation + * @see Rules + * @since 1.0.0 + */ +public enum FieldOperationType { + + /** + * A field is renamed to a new name. + * + * @see Rules#renameField + */ + RENAME("rename", true), + + /** + * A field is removed from the data structure. + * + * @see Rules#removeField + */ + REMOVE("remove", false), + + /** + * A new field is added with a default value (only if not already present). + * + * @see Rules#addField + */ + ADD("add", false), + + /** + * A field's value is transformed by a function. + * + * @see Rules#transformField + */ + TRANSFORM("transform", false), + + /** + * A field is set to a value, overwriting any existing value. + * + * @see Rules#setField + */ + SET("set", false), + + /** + * A field is moved from one location to another. + * + * @see Rules#moveField + */ + MOVE("move", true), + + /** + * A field's value is copied to another location (original preserved). + * + * @see Rules#copyField + */ + COPY("copy", true), + + /** + * Multiple fields are grouped into a nested object. + * + * @see Rules#groupFields + */ + GROUP("group", true), + + /** + * A nested object's fields are flattened into the parent. + * + * @see Rules#flattenField + */ + FLATTEN("flatten", false), + + /** + * A conditional operation that executes a rule based on field presence, absence, or value. + * + * @see Rules#ifFieldExists + * @see Rules#ifFieldMissing + * @see Rules#ifFieldEquals + */ + CONDITIONAL("conditional", false); + + /** + * Human-readable lowercase display name for this operation type. + */ + private final String displayName; + + /** + * Whether this operation type requires a target field name (e.g., rename target, move/copy destination). + */ + private final boolean requiresTarget; + + /** + * Creates a new field operation type constant. + * + * @param displayName the human-readable lowercase display name + * @param requiresTarget whether this operation requires a target field name + */ + FieldOperationType(@NotNull final String displayName, final boolean requiresTarget) { + this.displayName = displayName; + this.requiresTarget = requiresTarget; + } + + /** + * Returns the human-readable lowercase display name for this operation type. + * + *

    For example, {@link #RENAME} returns {@code "rename"} and + * {@link #CONDITIONAL} returns {@code "conditional"}.

    + * + * @return the display name, never {@code null} + */ + @NotNull + public String displayName() { + return this.displayName; + } + + /** + * Returns whether this operation type requires a target field name. + * + *

    Operations that produce a target include:

    + *
      + *
    • {@link #RENAME} — the new field name
    • + *
    • {@link #MOVE} — the destination path
    • + *
    • {@link #COPY} — the destination path
    • + *
    • {@link #GROUP} — the name of the new nested object
    • + *
    + * + * @return {@code true} if this operation type has a target field + */ + public boolean requiresTarget() { + return this.requiresTarget; + } + + /** + * Returns whether this operation type modifies the data structure layout rather than + * just individual field values. + * + *

    Structural operations change the shape of the data (nesting, flattening, moving fields + * between levels), while non-structural operations modify field values in place or add/remove + * individual fields at the same level.

    + * + *

    Structural types: {@link #MOVE}, {@link #COPY}, {@link #GROUP}, {@link #FLATTEN}.

    + * + * @return {@code true} if this is a structural operation + */ + public boolean isStructural() { + return this == MOVE || this == COPY || this == GROUP || this == FLATTEN; + } + + /** + * Returns the human-readable display name. + * + * @return the display name, never {@code null} + */ + @Override + @NotNull + public String toString() { + return this.displayName; + } +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java index 44f24cf..6e5df6f 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java @@ -152,6 +152,31 @@ public int matchedRuleCount() { .count(); } + /** + * Returns all field operations across all rule applications in this fix. + * + * @return unmodifiable list of all field operations, never {@code null} + * @since 1.0.0 + */ + @NotNull + public List allFieldOperations() { + return this.ruleApplications.stream() + .flatMap(r -> r.fieldOperations().stream()) + .toList(); + } + + /** + * Returns the total number of field operations across all rule applications. + * + * @return total field operation count + * @since 1.0.0 + */ + public int fieldOperationCount() { + return this.ruleApplications.stream() + .mapToInt(r -> r.fieldOperations().size()) + .sum(); + } + /** * Returns a human-readable summary of this fix execution. * diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java index bca8c76..9a39562 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java @@ -157,6 +157,18 @@ default int ruleApplicationCount() { .sum(); } + /** + * Returns the total number of field-level operations across all fixes and rule applications. + * + * @return total field operation count + * @since 1.0.0 + */ + default int totalFieldOperationCount() { + return this.fixExecutions().stream() + .mapToInt(FixExecution::fieldOperationCount) + .sum(); + } + // ------------------------------------------------------------------------- // Touched Types // ------------------------------------------------------------------------- diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java index cce6961..3fd4d75 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java @@ -28,6 +28,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Optional; /** @@ -49,15 +50,19 @@ * } * } * - * @param ruleName the name of the rule (from {@code TypeRewriteRule.toString()}) - * @param typeName the name of the type being processed - * @param timestamp the instant when the rule was applied - * @param duration the time taken to apply the rule - * @param matched whether the rule matched and transformed the data - * @param description optional additional description or context + * @param ruleName the name of the rule (from {@code TypeRewriteRule.toString()}) + * @param typeName the name of the type being processed + * @param timestamp the instant when the rule was applied + * @param duration the time taken to apply the rule + * @param matched whether the rule matched and transformed the data + * @param description optional additional description or context + * @param fieldOperations structured metadata about field-level operations performed by this rule; + * empty if the rule does not carry field-level metadata or if field detail + * capture is disabled (since 1.0.0) * @author Erik Pförtner * @see FixExecution * @see MigrationReport + * @see FieldOperation * @since 0.2.0 */ public record RuleApplication( @@ -66,18 +71,20 @@ public record RuleApplication( @NotNull Instant timestamp, @NotNull Duration duration, boolean matched, - @Nullable String description + @Nullable String description, + @NotNull List fieldOperations ) { /** * Creates a new rule application record. * - * @param ruleName the name of the rule, must not be {@code null} - * @param typeName the name of the type being processed, must not be {@code null} - * @param timestamp the instant when the rule was applied, must not be {@code null} - * @param duration the time taken to apply the rule, must not be {@code null} - * @param matched whether the rule matched and transformed the data - * @param description optional additional description (may be {@code null}) + * @param ruleName the name of the rule, must not be {@code null} + * @param typeName the name of the type being processed, must not be {@code null} + * @param timestamp the instant when the rule was applied, must not be {@code null} + * @param duration the time taken to apply the rule, must not be {@code null} + * @param matched whether the rule matched and transformed the data + * @param description optional additional description (may be {@code null}) + * @param fieldOperations field-level operation metadata, must not be {@code null} * @throws NullPointerException if any required parameter is {@code null} */ public RuleApplication { @@ -85,10 +92,12 @@ public record RuleApplication( Preconditions.checkNotNull(typeName, "typeName must not be null"); Preconditions.checkNotNull(timestamp, "timestamp must not be null"); Preconditions.checkNotNull(duration, "duration must not be null"); + Preconditions.checkNotNull(fieldOperations, "fieldOperations must not be null"); + fieldOperations = List.copyOf(fieldOperations); } /** - * Creates a new rule application record without a description. + * Creates a new rule application record without a description or field operations. * * @param ruleName the name of the rule, must not be {@code null} * @param typeName the name of the type being processed, must not be {@code null} @@ -105,7 +114,7 @@ public static RuleApplication of( @NotNull final Duration duration, final boolean matched ) { - return new RuleApplication(ruleName, typeName, timestamp, duration, matched, null); + return new RuleApplication(ruleName, typeName, timestamp, duration, matched, null, List.of()); } /** @@ -127,6 +136,32 @@ public long durationMillis() { return this.duration.toMillis(); } + /** + * Returns whether this rule application carries field-level operation metadata. + * + * @return {@code true} if this application has at least one field operation + * @since 1.0.0 + */ + public boolean hasFieldOperations() { + return !this.fieldOperations.isEmpty(); + } + + /** + * Returns the field operations of a specific type. + * + * @param type the operation type to filter by, must not be {@code null} + * @return an unmodifiable list of matching field operations, never {@code null} + * @throws NullPointerException if {@code type} is {@code null} + * @since 1.0.0 + */ + @NotNull + public List fieldOperationsOfType(@NotNull final FieldOperationType type) { + Preconditions.checkNotNull(type, "type must not be null"); + return this.fieldOperations.stream() + .filter(op -> op.operationType() == type) + .toList(); + } + /** * Returns a human-readable summary of this rule application. * @@ -134,11 +169,15 @@ public long durationMillis() { */ @NotNull public String toSummary() { - return String.format("%s on %s: %s in %dms", + final String base = String.format("%s on %s: %s in %dms", this.ruleName, this.typeName, this.matched ? "matched" : "skipped", this.durationMillis() ); + if (this.fieldOperations.isEmpty()) { + return base; + } + return base + String.format(" (%d field ops)", this.fieldOperations.size()); } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java index f0eb96d..c3ca3fa 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java @@ -31,6 +31,7 @@ *
  • Timing information for the overall migration and individual fixes
  • *
  • Details about each applied {@link de.splatgames.aether.datafixers.api.fix.DataFix}
  • *
  • Individual {@link de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule} applications
  • + *
  • Field-level operation metadata from {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} implementations
  • *
  • Before/after data snapshots for debugging
  • *
  • Warnings and diagnostic messages
  • * @@ -52,6 +53,12 @@ * *
    {@link de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions}
    *
    Configuration for controlling what data is captured
    + * + *
    {@link de.splatgames.aether.datafixers.api.diagnostic.FieldOperation}
    + *
    Structured metadata about a single field-level operation within a rule
    + * + *
    {@link de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType}
    + *
    Classifies the kind of field-level operation (rename, remove, add, etc.)
    * * *

    Usage Example

    diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/TaggedDynamic.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/TaggedDynamic.java index 49277a9..cd2f3b0 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/TaggedDynamic.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/dynamic/TaggedDynamic.java @@ -25,6 +25,7 @@ import com.google.common.base.Preconditions; import de.splatgames.aether.datafixers.api.TypeReference; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * A {@link Dynamic} value paired with a {@link TypeReference} identifier. @@ -200,7 +201,7 @@ public Dynamic value() { * @return {@code true} if this object is the same as the obj argument; {@code false} otherwise */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -237,6 +238,7 @@ public int hashCode() { * @return a string representation of the object */ @Override + @NotNull public String toString() { return "TaggedDynamic{" + "type=" + this.type + diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/exception/DataFixerException.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/exception/DataFixerException.java index f8d5ed8..cb261bb 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/exception/DataFixerException.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/exception/DataFixerException.java @@ -155,6 +155,7 @@ public String getContext() { * @return the message with context, or just the message if no context */ @Override + @NotNull public String toString() { final String base = getClass().getSimpleName() + ": " + getMessage(); if (this.context != null) { diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/fix/Fixes.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/fix/Fixes.java index b5b8f14..3bc30b5 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/fix/Fixes.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/fix/Fixes.java @@ -147,6 +147,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return name; } @@ -210,6 +211,7 @@ public Optional> rewrite(@NotNull final Type inputType, } @Override + @NotNull public String toString() { return name; } @@ -309,6 +311,7 @@ public static TypeRewriteRule addField(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { return "addField(" + fieldName + ")"; } @@ -390,6 +393,7 @@ public static TypeRewriteRule fixChoice(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { return "fixChoice(" + tagField + ", " + fixByTag.keySet() + ")"; } @@ -447,6 +451,7 @@ public static TypeRewriteRule renameChoice(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { return "renameChoice(" + tagField + ": " + oldTag + " -> " + newTag + ")"; } @@ -520,6 +525,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return name; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/result/DataResult.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/result/DataResult.java index 7d2f5c1..163a038 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/result/DataResult.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/result/DataResult.java @@ -778,7 +778,7 @@ public DataResult promotePartial(@NotNull final Consumer onError) { * @return {@code true} if the other object is a Success with an equal value */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -803,8 +803,8 @@ public int hashCode() { * * @return a string in the format "DataResult.Success[value]" */ - @NotNull @Override + @NotNull public String toString() { return "DataResult.Success[" + this.value + "]"; } @@ -1150,7 +1150,7 @@ public DataResult promotePartial(@NotNull final Consumer onError) { * @return {@code true} if the other object is an Error with equal message and partial */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -1177,8 +1177,8 @@ public int hashCode() { * * @return a string in the format "DataResult.Error[message]" or "DataResult.Error[message, partial=value]" */ - @NotNull @Override + @NotNull public String toString() { if (this.partial != null) { return "DataResult.Error[" + this.message + ", partial=" + this.partial + "]"; diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java index 334cf1c..a8d0ef0 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java @@ -23,6 +23,8 @@ package de.splatgames.aether.datafixers.api.rewrite; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; import de.splatgames.aether.datafixers.api.dynamic.Dynamic; import de.splatgames.aether.datafixers.api.dynamic.DynamicOps; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -68,6 +70,12 @@ *
  • {@link #addIfMissing(String, Function)} - Add field only if missing
  • * * + *

    Diagnostics

    + *

    The {@link #diagnosticFieldOperations()} method provides structured metadata + * about all batch operations for the diagnostic system. When used via {@link Rules#batch}, + * this metadata is automatically attached to the resulting rule as + * {@link FieldOperation} records.

    + * *

    Thread Safety

    *

    This class is not thread-safe during construction. Once built into a * {@link TypeRewriteRule} via {@link Rules#batch}, the resulting rule is thread-safe.

    @@ -79,7 +87,7 @@ */ public final class BatchTransform { - private final List> operations = new ArrayList<>(); + private final List> operations = new ArrayList<>(); @SuppressFBWarnings( value = "EI_EXPOSE_REP2", justification = "DynamicOps is a stateless strategy object stored for future extensions of this builder; exposing/copying is neither required nor meaningful." @@ -240,7 +248,7 @@ public Dynamic apply(@NotNull final Dynamic input) { Preconditions.checkNotNull(input, "input must not be null"); Dynamic result = input; - for (final FieldOperation op : this.operations) { + for (final BatchOp op : this.operations) { result = op.apply(result); } return result; @@ -264,6 +272,59 @@ public boolean isEmpty() { return this.operations.isEmpty(); } + /** + * Returns diagnostic field operation metadata for all operations in this batch. + * + *

    This method converts the internal batch operations to + * {@link FieldOperation} records + * for use by the diagnostic system. Each batch operation is mapped to its + * corresponding diagnostic field operation type.

    + * + * @return an unmodifiable list of diagnostic field operations, never {@code null} + * @since 1.0.0 + */ + @NotNull + public List diagnosticFieldOperations() { + return this.operations.stream() + .map(BatchTransform::toDiagnosticFieldOp) + .toList(); + } + + /** + * Converts an internal batch operation to a diagnostic field operation record. + * + * @param op the internal operation to convert + * @param the underlying data format type + * @return the corresponding diagnostic field operation + * @since 1.0.0 + */ + @NotNull + private static FieldOperation toDiagnosticFieldOp( + @NotNull final BatchOp op) { + if (op instanceof RenameOp r) { + return FieldOperation.rename(r.from(), r.to()); + } + if (op instanceof RemoveOp r) { + return FieldOperation.remove(r.field()); + } + if (op instanceof SetOp r) { + return FieldOperation.set(r.field()); + } + if (op instanceof TransformOp r) { + return FieldOperation.transform(r.field()); + } + if (op instanceof AddIfMissingOp r) { + return FieldOperation.add(r.field()); + } + // Fallback for any future operation types + return new FieldOperation( + FieldOperationType.TRANSFORM, + List.of("unknown"), + null, + "unknown batch operation" + ); + } + // ==================== Internal Operation Classes ==================== /** @@ -276,7 +337,7 @@ public boolean isEmpty() { * @param the underlying data format type * @since 0.4.0 */ - private interface FieldOperation { + private interface BatchOp { /** * Applies this operation to the given dynamic value. @@ -299,7 +360,7 @@ private interface FieldOperation { * @param the underlying data format type * @since 0.4.0 */ - private record RenameOp(String from, String to) implements FieldOperation { + private record RenameOp(String from, String to) implements BatchOp { /** * {@inheritDoc} @@ -330,7 +391,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @param the underlying data format type * @since 0.4.0 */ - private record RemoveOp(String field) implements FieldOperation { + private record RemoveOp(String field) implements BatchOp { /** * {@inheritDoc} @@ -358,7 +419,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @param the underlying data format type * @since 0.4.0 */ - private record SetOp(String field, Function, Dynamic> valueSupplier) implements FieldOperation { + private record SetOp(String field, Function, Dynamic> valueSupplier) implements BatchOp { /** * {@inheritDoc} @@ -387,7 +448,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @since 0.4.0 */ private record TransformOp(String field, - Function, Dynamic> transform) implements FieldOperation { + Function, Dynamic> transform) implements BatchOp { /** * {@inheritDoc} @@ -421,7 +482,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @since 0.4.0 */ private record AddIfMissingOp(String field, - Function, Dynamic> valueSupplier) implements FieldOperation { + Function, Dynamic> valueSupplier) implements BatchOp { /** * {@inheritDoc} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java new file mode 100644 index 0000000..b4aea67 --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.rewrite; + +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * A marker interface for {@link TypeRewriteRule} implementations that carry + * structured field-level metadata. + * + *

    {@code FieldAwareRule} is a separate interface rather than a default method + * on {@link TypeRewriteRule} because {@code TypeRewriteRule} is a + * {@link FunctionalInterface} and cannot have additional abstract methods. + * Rules created by the field operation methods in {@link Rules} (such as + * {@code renameField}, {@code removeField}, etc.) implement both + * {@code TypeRewriteRule} and {@code FieldAwareRule}.

    + * + *

    Usage in Diagnostics

    + *

    The diagnostic system uses {@code instanceof FieldAwareRule} to detect + * whether a rule carries field-level metadata. When present, the metadata is + * included in the {@link RuleApplication} + * record for that rule execution.

    + * + *

    Usage Example

    + *
    {@code
    + * TypeRewriteRule rule = Rules.renameField(ops, "oldName", "newName");
    + *
    + * if (rule instanceof FieldAwareRule fieldAware) {
    + *     List fieldOps = fieldAware.fieldOperations();
    + *     // fieldOps contains: [FieldOperation.rename("oldName", "newName")]
    + * }
    + * }
    + * + *

    Implementing Custom Field-Aware Rules

    + *

    Custom rules can implement this interface to participate in field-level + * diagnostics:

    + *
    {@code
    + * public class MyCustomRule implements TypeRewriteRule, FieldAwareRule {
    + *     @Override
    + *     public Optional> rewrite(Type type, Typed input) {
    + *         // rule logic
    + *     }
    + *
    + *     @Override
    + *     public List fieldOperations() {
    + *         return List.of(FieldOperation.transform("myField"));
    + *     }
    + * }
    + * }
    + * + * @author Erik Pförtner + * @see TypeRewriteRule + * @see FieldOperation + * @see Rules + * @since 1.0.0 + */ +public interface FieldAwareRule { + + /** + * Returns the list of field-level operations that this rule performs. + * + *

    The returned list describes which fields are affected and how. For + * simple rules (e.g., a single {@code renameField}), this typically contains + * one entry. For batch rules (e.g., {@code renameFields} with multiple + * mappings), this may contain multiple entries.

    + * + *

    The returned list must be immutable.

    + * + * @return an unmodifiable list of field operations, never {@code null} + */ + @NotNull + List fieldOperations(); +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java index 1c47e68..7677434 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java @@ -23,6 +23,10 @@ package de.splatgames.aether.datafixers.api.rewrite; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import de.splatgames.aether.datafixers.api.dynamic.Dynamic; import de.splatgames.aether.datafixers.api.dynamic.DynamicOps; import de.splatgames.aether.datafixers.api.optic.Finder; @@ -31,6 +35,7 @@ import de.splatgames.aether.datafixers.api.type.Typed; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -41,67 +46,246 @@ import java.util.function.Predicate; /** - * Factory class providing common combinators for building {@link TypeRewriteRule} instances. + * Factory class providing combinators and field-level operations for building + * {@link TypeRewriteRule} instances. * - *

    The {@code Rules} class is a comprehensive toolkit for constructing data migration rules. - * It provides a rich set of combinators that allow complex migration logic to be built from simple, composable - * primitives. These combinators follow functional programming patterns and enable declarative specification of data - * transformations.

    + *

    {@code Rules} is the canonical entry point for constructing data migration + * logic in Aether Datafixers. It exposes a rich, type-safe DSL of small, + * composable primitives that can be combined into arbitrarily complex + * transformations. Every factory method here returns a stateless, thread-safe + * {@link TypeRewriteRule} that can be reused across migrations.

    + * + *

    Every field-operation method (rename, remove, add, transform, batch + * variants, path-based variants, and conditionals) returns a rule that + * implements {@link FieldAwareRule} and carries structured + * {@link FieldOperation} metadata. When such a rule runs inside a + * {@link DiagnosticContext}, the + * resulting {@link MigrationReport + * MigrationReport} captures exactly which fields were touched and how — not + * merely that a rule ran. The composition combinators + * ({@link #seq}, {@link #seqAll}, {@link #choice}, {@link #batch}) + * transparently aggregate this metadata from their children, so a single + * composed rule surfaces all of its sub-operations as a unified group.

    * *

    Combinator Categories

    + * + *

    1. Basic Composition (sequence and choice)

    + *

    These combinators stitch other rules together. They preserve and aggregate + * field operation metadata from their children, so a {@code seq} of three + * field-aware rules surfaces as a single rule whose + * {@link FieldAwareRule#fieldOperations()} contains all three operations + * flattened in order.

    *
      - *
    • Basic Combinators: {@link #seq}, {@link #seqAll}, {@link #choice}, - * {@link #checkOnce}, {@link #tryOnce}
    • - *
    • Traversal Combinators: {@link #all}, {@link #one}, {@link #everywhere}, - * {@link #bottomUp}, {@link #topDown}
    • - *
    • Type-Specific: {@link #ifType}, {@link #transformType}
    • - *
    • Field Operations: {@link #renameField}, {@link #removeField}, - * {@link #addField}, {@link #transformField}
    • - *
    • Utilities: {@link #noop}, {@link #log}
    • + *
    • {@link #seq(TypeRewriteRule...) seq} — Apply rules in order; all must + * succeed. The result of each rule feeds the next (AND semantics).
    • + *
    • {@link #seqAll(TypeRewriteRule...) seqAll} — Like {@code seq}, but + * failures are tolerated and the next rule receives the previous output + * unchanged (forgiving AND).
    • + *
    • {@link #choice(TypeRewriteRule...) choice} — First successful rule wins + * (OR semantics). Field operations from all alternatives are + * aggregated into the metadata, since any of them might match at runtime.
    • + *
    • {@link #checkOnce(TypeRewriteRule) checkOnce} — Apply the inner rule a + * single time without recursing into the result.
    • + *
    • {@link #tryOnce(TypeRewriteRule) tryOnce} — Like {@code checkOnce}, but + * silently swallows failures (returns the input unchanged).
    • *
    * - *

    Usage Example

    - *
    {@code
    - * // Build a complex migration rule using combinators
    - * TypeRewriteRule migration = Rules.seq(
    - *     // First, rename the old field
    - *     Rules.renameField(GsonOps.INSTANCE, "playerName", "name"),
    + * 

    2. Traversal Combinators

    + *

    These walk recursive data structures and apply a rule at one or more + * positions. Each combinator has two overloads: one with an explicit + * {@link DynamicOps} for {@link Dynamic}-based traversal, and a higher-level + * overload that operates on the {@link Type} system.

    + *
      + *
    • {@link #all(TypeRewriteRule) all} — Apply the rule to every immediate + * child of the current node. All children must succeed.
    • + *
    • {@link #one(TypeRewriteRule) one} — Apply the rule to exactly one + * child; succeed as soon as one match is found.
    • + *
    • {@link #everywhere(TypeRewriteRule) everywhere} — Apply the rule at the + * current node and recursively at every descendant.
    • + *
    • {@link #bottomUp(TypeRewriteRule) bottomUp} — Recurse first, then apply + * the rule on the way back up (leaves before parents).
    • + *
    • {@link #topDown(TypeRewriteRule) topDown} — Apply the rule to the + * current node first, then recurse into the result (parents before leaves).
    • + *
    + * + *

    3. Type Filters and Type-Aware Updates

    + *
      + *
    • {@link #ifType(Type, TypeRewriteRule) ifType} — Apply the inner rule + * only if the current value matches the given {@link Type}; otherwise + * leave the value unchanged.
    • + *
    • {@link #transformType(String, Type, Function) transformType} — Apply a + * value-level {@code A -> A} transformation to every occurrence of a + * given {@link Type} in the structure, named for diagnostics.
    • + *
    • {@link #updateAt(String, DynamicOps, Finder, Function) updateAt} — + * Update the {@link Dynamic} at a position located by a {@link Finder}, + * leaving everything else intact.
    • + *
    + * + *

    4. Top-Level Field Operations

    + *

    These are the most common building blocks. Each operates on a single, + * top-level field of a {@link Dynamic} map and returns a + * {@link FieldAwareRule} carrying the corresponding {@link FieldOperation}.

    + *
      + *
    • {@link #renameField(DynamicOps, String, String) renameField} — Rename + * a field, preserving its value.
    • + *
    • {@link #removeField(DynamicOps, String) removeField} — Drop a field + * entirely.
    • + *
    • {@link #addField(DynamicOps, String, Dynamic) addField} — Add a field + * with a default value, only if it does not already exist.
    • + *
    • {@link #transformField(DynamicOps, String, Function) transformField} — + * Apply a {@code Dynamic -> Dynamic} function to an existing field.
    • + *
    • {@link #setField(DynamicOps, String, Dynamic) setField} — Unconditionally + * set a field's value, overwriting any existing value.
    • + *
    * - * // Then add a default score if missing - * Rules.addField(GsonOps.INSTANCE, "score", - * new Dynamic<>(GsonOps.INSTANCE, JsonPrimitive(0))), + *

    5. Batch Field Operations

    + *

    Equivalents that operate on many fields at once for performance — useful + * when migrating dozens of fields in the same step. They return a single rule + * whose field-operation metadata contains one entry per affected field.

    + *
      + *
    • {@link #renameFields(DynamicOps, Map) renameFields} — Rename many + * fields in a single pass, given an old-name → new-name map.
    • + *
    • {@link #removeFields(DynamicOps, String...) removeFields} — Remove + * multiple fields in a single pass.
    • + *
    • {@link #groupFields(DynamicOps, String, String...) groupFields} — + * Collapse a set of flat fields into a nested object.
    • + *
    • {@link #flattenField(DynamicOps, String) flattenField} — The inverse: + * lift the entries of a nested object into the parent.
    • + *
    • {@link #moveField(DynamicOps, String, String) moveField} — Relocate a + * field (possibly across nesting levels), removing the source.
    • + *
    • {@link #copyField(DynamicOps, String, String) copyField} — Like + * {@code moveField} but keeps the source intact.
    • + *
    • {@link #batch(DynamicOps, Consumer) batch} — Imperative builder for + * composing many of the above operations into a single rule with shared + * metadata; useful for very large per-step migrations.
    • + *
    * - * // Finally, transform the level field - * Rules.transformField(GsonOps.INSTANCE, "level", - * d -> d.createInt(d.asInt().orElse(0) + 1)) + *

    6. Path-Based (Nested) Field Operations

    + *

    Variants of the top-level operations that accept a dot-notation path + * (e.g. {@code "position.x"}) for navigating into nested objects. Internally + * they use {@link Finder} optics; the resulting rule carries a + * {@link FieldOperation} whose {@code fieldPath} reflects the nested structure.

    + *
      + *
    • {@link #transformFieldAt(DynamicOps, String, Function) transformFieldAt}
    • + *
    • {@link #renameFieldAt(DynamicOps, String, String) renameFieldAt}
    • + *
    • {@link #removeFieldAt(DynamicOps, String) removeFieldAt}
    • + *
    • {@link #addFieldAt(DynamicOps, String, Dynamic) addFieldAt}
    • + *
    + * + *

    7. Conditional Field Operations

    + *

    Apply an inner rule only when a field-level condition is satisfied. These + * are typically composed with the field combinators above to express "migrate + * X only when Y looks like Z" patterns. The diagnostic metadata records the + * condition itself as a {@link FieldOperation} of type + * {@link FieldOperationType#CONDITIONAL}.

    + *
      + *
    • {@link #ifFieldExists(DynamicOps, String, TypeRewriteRule) ifFieldExists} — + * Apply the inner rule only when a named field is present.
    • + *
    • {@link #ifFieldMissing(DynamicOps, String, TypeRewriteRule) ifFieldMissing} — + * Apply the inner rule only when a named field is absent.
    • + *
    • {@link #ifFieldEquals(DynamicOps, String, Object, TypeRewriteRule) ifFieldEquals} — + * Apply the inner rule only when a field equals a given value.
    • + *
    • {@link #conditionalTransform(DynamicOps, Predicate, Function) conditionalTransform} — + * General-purpose predicate-based transformation for cases the + * specialised helpers do not cover.
    • + *
    + * + *

    8. Escape Hatches and Utilities

    + *
      + *
    • {@link #dynamicTransform(String, DynamicOps, Function) dynamicTransform} — + * Wrap an arbitrary {@code Dynamic -> Dynamic} function as a rule. Use + * this when none of the higher-level combinators fits; the resulting + * rule does not carry field-operation metadata, so the diagnostic + * system reports it as opaque.
    • + *
    • {@link #noop() noop} — A rule that returns its input unchanged. Useful + * as a placeholder or as the {@code else}-branch of a choice.
    • + *
    • {@link #log(String, TypeRewriteRule) log} — Wrap a rule in SLF4J + * logging for debugging migrations.
    • + *
    + * + *

    Putting It Together — A Realistic Migration

    + *
    {@code
    + * // Migrate a player save from v1 to v2:
    + * //   - rename "playerName" to "name"
    + * //   - drop the legacy "lastSeen" field
    + * //   - regroup x/y/z coordinates into a nested "position" object
    + * //   - add a default "health" field
    + * //   - bump "level" by one — but only if it currently exists
    + * //   - all bundled into a single sequence so the diagnostics report
    + * //     attributes every change to the migration step.
    + * TypeRewriteRule playerV1ToV2 = Rules.seq(
    + *     Rules.renameField(GsonOps.INSTANCE, "playerName", "name"),
    + *     Rules.removeField(GsonOps.INSTANCE, "lastSeen"),
    + *     Rules.groupFields(GsonOps.INSTANCE, "position", "x", "y", "z"),
    + *     Rules.addField(GsonOps.INSTANCE, "health",
    + *         new Dynamic<>(GsonOps.INSTANCE, GsonOps.INSTANCE.createInt(100))),
    + *     Rules.ifFieldExists(GsonOps.INSTANCE, "level",
    + *         Rules.transformField(GsonOps.INSTANCE, "level",
    + *             d -> d.createInt(d.asInt().result().orElse(0) + 1)))
      * );
      *
    - * // Apply the migration
    - * Typed result = migration.apply(inputData);
    + * // When run with a DiagnosticContext, the resulting MigrationReport contains
    + * // one FieldOperation per top-level rule above (5 entries: RENAME, REMOVE,
    + * // GROUP, ADD, CONDITIONAL — the inner TRANSFORM is recorded under the
    + * // CONDITIONAL wrapper).
      * }
    * - *

    Sequencing vs Choice

    + *

    Sequencing vs Choice — Quick Reference

    *
      - *
    • {@link #seq} - All rules must succeed (AND-like)
    • - *
    • {@link #seqAll} - Apply all rules, continue on failure (forgiving AND)
    • - *
    • {@link #choice} - First successful rule wins (OR-like)
    • + *
    • {@link #seq} — All rules must succeed (AND-like). The output of each + * rule feeds the next.
    • + *
    • {@link #seqAll} — Apply all rules, but tolerate individual failures + * (forgiving AND). The next rule always sees the previous output.
    • + *
    • {@link #choice} — First successful rule wins (OR-like). Subsequent + * alternatives are not evaluated.
    • *
    * - *

    Traversal Strategies

    - *

    For recursive data structures:

    + *

    Traversal Strategies — Quick Reference

    + *

    For recursive structures (lists, nested maps, sums of types):

    *
      - *
    • {@link #topDown} - Apply rule to parent first, then children
    • - *
    • {@link #bottomUp} - Apply rule to children first, then parent
    • - *
    • {@link #everywhere} - Apply rule at all levels
    • + *
    • {@link #topDown} — Apply the rule at the parent first, then recurse + * into the result. Use when the migration changes the shape of children + * and the parent rule must see the original structure.
    • + *
    • {@link #bottomUp} — Recurse into children first, then apply the rule + * at the parent. Use when the parent rule needs the already-migrated + * children to make a decision.
    • + *
    • {@link #everywhere} — Apply at every node, parents and children, in a + * single combined pass.
    • *
    * + *

    Custom Rules and the Field-Aware Marker

    + *

    Rules created via {@link #dynamicTransform} or by hand-implementing + * {@link TypeRewriteRule} are not field-aware by default — the + * diagnostic system records them but cannot break them down by field. If you + * write a custom rule and want diagnostic visibility, also implement + * {@link FieldAwareRule} and return the operations your rule performs from + * {@link FieldAwareRule#fieldOperations()}.

    + * *

    Thread Safety

    - *

    All factory methods return stateless, thread-safe rules. The same rule - * instance can be used concurrently for multiple migrations.

    + *

    All factory methods return stateless, thread-safe rules. A rule built + * once at application start can be reused concurrently for any number of + * migrations. The internal {@link Finder} cache used by the path-based + * methods is also thread-safe.

    + * + *

    Performance Notes

    + *
      + *
    • Path parsing for {@code *FieldAt} methods is cached, so repeating the + * same path across many rules has no per-call cost after the first.
    • + *
    • Composition combinators construct flat metadata lists eagerly when the + * rule is built, not at apply time, so diagnostic capture imposes + * essentially zero overhead per migration.
    • + *
    • Prefer the batch variants ({@link #renameFields}, {@link #removeFields}, + * {@link #batch}) over many individual calls when migrating many fields + * in the same step — they avoid repeated map traversals.
    • + *
    * * @author Erik Pförtner * @see TypeRewriteRule + * @see FieldAwareRule + * @see FieldOperation * @see Finder + * @see MigrationReport + * @see DiagnosticContext * @since 0.1.0 */ public final class Rules { @@ -138,6 +322,11 @@ private Rules() { * Typed result = migration.apply(playerData); * }
    * + *

    Field-Level Diagnostics

    + *

    If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata. This allows the diagnostic system to report which fields are + * affected even through compositions.

    + * * @param rules the rules to apply in sequence; if empty, returns identity rule * @return a composed rule requiring all rules to match, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -151,7 +340,8 @@ public static TypeRewriteRule seq(@NotNull final TypeRewriteRule... rules) { if (rules.length == 1) { return rules[0]; } - return new TypeRewriteRule() { + final String ruleName = "seq(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public Optional> rewrite(@NotNull final Type type, @@ -170,10 +360,16 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { - return "seq(" + Arrays.toString(rules) + ")"; + return ruleName; } }; + final List aggregated = collectFieldOperations(rules); + if (!aggregated.isEmpty()) { + return withFieldOps(base, aggregated, ruleName); + } + return base; } /** @@ -196,6 +392,10 @@ public String toString() { * Typed result = migration.apply(data); * } * + *

    Field-Level Diagnostics

    + *

    If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata for diagnostic reporting.

    + * * @param rules the rules to try in sequence; non-matching rules are skipped * @return a composed rule that always succeeds, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -203,7 +403,8 @@ public String toString() { @NotNull public static TypeRewriteRule seqAll(@NotNull final TypeRewriteRule... rules) { Preconditions.checkNotNull(rules, "rules must not be null"); - return new TypeRewriteRule() { + final String ruleName = "seqAll(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public Optional> rewrite(@NotNull final Type type, @@ -218,10 +419,16 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { - return "seqAll(" + Arrays.toString(rules) + ")"; + return ruleName; } }; + final List aggregated = collectFieldOperations(rules); + if (!aggregated.isEmpty()) { + return withFieldOps(base, aggregated, ruleName); + } + return base; } /** @@ -244,6 +451,11 @@ public String toString() { * Typed result = versionFix.apply(data); * } * + *

    Field-Level Diagnostics

    + *

    If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata from all alternatives, since any one of them may match at + * runtime.

    + * * @param rules the rules to try in order; first match wins * @return a composed rule that uses the first matching rule, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -251,7 +463,8 @@ public String toString() { @NotNull public static TypeRewriteRule choice(@NotNull final TypeRewriteRule... rules) { Preconditions.checkNotNull(rules, "rules must not be null"); - return new TypeRewriteRule() { + final String ruleName = "choice(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public Optional> rewrite(@NotNull final Type type, @@ -268,10 +481,16 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { - return "choice(" + Arrays.toString(rules) + ")"; + return ruleName; } }; + final List aggregated = collectFieldOperations(rules); + if (!aggregated.isEmpty()) { + return withFieldOps(base, aggregated, ruleName); + } + return base; } /** @@ -309,6 +528,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "checkOnce(" + rule + ")"; } @@ -412,6 +632,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "all(" + rule + ")"; } @@ -445,6 +666,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "all(" + rule + ")"; } @@ -521,6 +743,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "one(" + rule + ")"; } @@ -550,6 +773,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "one(" + rule + ")"; } @@ -621,6 +845,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "everywhere(" + rule + ")"; } @@ -650,6 +875,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "everywhere(" + rule + ")"; } @@ -718,6 +944,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "bottomUp(" + rule + ")"; } @@ -747,6 +974,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "bottomUp(" + rule + ")"; } @@ -817,6 +1045,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "topDown(" + rule + ")"; } @@ -846,6 +1075,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return "topDown(" + rule + ")"; } @@ -911,8 +1141,9 @@ public static TypeRewriteRule updateAt(@NotNull final String name, Preconditions.checkNotNull(updater, "updater must not be null"); return new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -921,6 +1152,7 @@ public static TypeRewriteRule updateAt(@NotNull final String name, } @Override + @NotNull public String toString() { return name + "[" + finder.id() + "]"; } @@ -960,10 +1192,12 @@ public static TypeRewriteRule renameField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(oldName, "oldName must not be null"); Preconditions.checkNotNull(newName, "newName must not be null"); - return new TypeRewriteRule() { + final String ruleName = "renameField(" + oldName + " -> " + newName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -982,10 +1216,12 @@ public static TypeRewriteRule renameField(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { - return "renameField(" + oldName + " -> " + newName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.rename(oldName, newName)), ruleName); } /** @@ -1017,10 +1253,12 @@ public static TypeRewriteRule removeField(@NotNull final DynamicOps ops, @NotNull final String fieldName) { Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); - return new TypeRewriteRule() { + final String ruleName = "removeField(" + fieldName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -1033,10 +1271,12 @@ public static TypeRewriteRule removeField(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { - return "removeField(" + fieldName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.remove(fieldName)), ruleName); } /** @@ -1077,10 +1317,12 @@ public static TypeRewriteRule addField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(defaultValue, "defaultValue must not be null"); - return new TypeRewriteRule() { + final String ruleName = "addField(" + fieldName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -1099,10 +1341,12 @@ public static TypeRewriteRule addField(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { - return "addField(" + fieldName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.add(fieldName)), ruleName); } /** @@ -1145,12 +1389,9 @@ public static TypeRewriteRule transformField(@NotNull final DynamicOps op Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return updateAt( - "transformField(" + fieldName + ")", - ops, - Finder.field(fieldName), - transform - ); + final String ruleName = "transformField(" + fieldName + ")"; + final TypeRewriteRule base = updateAt(ruleName, ops, Finder.field(fieldName), transform); + return withFieldOps(base, List.of(FieldOperation.transform(fieldName)), ruleName); } // ==================== Batch Operations ==================== @@ -1181,6 +1422,10 @@ public static TypeRewriteRule transformField(@NotNull final DynamicOps op * ); * } * + *

    Field-Level Diagnostics

    + *

    The returned rule implements {@link FieldAwareRule} with field operation + * metadata derived from the batch operations (rename, remove, set, transform, addIfMissing).

    + * * @param the underlying data format type (e.g., JsonElement) * @param ops the dynamic operations for the data format, must not be {@code null} * @param builder a consumer that configures the batch operations, must not be {@code null} @@ -1202,10 +1447,16 @@ public static TypeRewriteRule batch(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return dynamicTransform("batch[" + batch.size() + " ops]", ops, dynamic -> { + final String ruleName = "batch[" + batch.size() + " ops]"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; return batch.apply(typedDynamic); }); + final List fieldOps = batch.diagnosticFieldOperations(); + if (!fieldOps.isEmpty()) { + return withFieldOps(base, fieldOps, ruleName); + } + return base; } // ==================== Extended Dynamic Transformation Combinators ==================== @@ -1263,6 +1514,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return name; } @@ -1303,11 +1555,12 @@ public static TypeRewriteRule setField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(value, "value must not be null"); - - return new TypeRewriteRule() { + final String ruleName = "setField(" + fieldName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -1320,10 +1573,12 @@ public static TypeRewriteRule setField(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { - return "setField(" + fieldName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.set(fieldName)), ruleName); } /** @@ -1365,10 +1620,12 @@ public static TypeRewriteRule renameFields(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return new TypeRewriteRule() { + final String ruleName = "renameFields(" + renames + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -1389,10 +1646,15 @@ public static TypeRewriteRule renameFields(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { - return "renameFields(" + renames + ")"; + return ruleName; } }; + final List fieldOps = renames.entrySet().stream() + .map(e -> FieldOperation.rename(e.getKey(), e.getValue())) + .toList(); + return withFieldOps(base, fieldOps, ruleName); } /** @@ -1430,10 +1692,12 @@ public static TypeRewriteRule removeFields(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return new TypeRewriteRule() { + final String ruleName = "removeFields(" + Arrays.toString(fieldNames) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override + @NotNull @SuppressWarnings({"unchecked", "rawtypes"}) - public @NotNull Optional> rewrite(@NotNull final Type type, + public Optional> rewrite(@NotNull final Type type, @NotNull final Typed input) { Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(input, "input must not be null"); @@ -1449,10 +1713,15 @@ public static TypeRewriteRule removeFields(@NotNull final DynamicOps ops, } @Override + @NotNull public String toString() { - return "removeFields(" + Arrays.toString(fieldNames) + ")"; + return ruleName; } }; + final List fieldOps = Arrays.stream(fieldNames) + .map(FieldOperation::remove) + .toList(); + return withFieldOps(base, fieldOps, ruleName); } // ==================== Grouping and Moving Combinators ==================== @@ -1496,7 +1765,8 @@ public static TypeRewriteRule groupFields(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return dynamicTransform("groupFields(" + targetField + ")", ops, dynamic -> { + final String ruleName = "groupFields(" + targetField + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") Dynamic typedDynamic = (Dynamic) dynamic; @@ -1516,6 +1786,7 @@ public static TypeRewriteRule groupFields(@NotNull final DynamicOps ops, } return result.set(targetField, nested); }); + return withFieldOps(base, List.of(FieldOperation.group(targetField, sourceFields)), ruleName); } /** @@ -1549,7 +1820,8 @@ public static TypeRewriteRule flattenField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); - return dynamicTransform("flattenField(" + fieldName + ")", ops, dynamic -> { + final String ruleName = "flattenField(" + fieldName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") Dynamic typedDynamic = (Dynamic) dynamic; @@ -1580,6 +1852,7 @@ public static TypeRewriteRule flattenField(@NotNull final DynamicOps ops, } return result; }); + return withFieldOps(base, List.of(FieldOperation.flatten(fieldName)), ruleName); } /** @@ -1620,7 +1893,8 @@ public static TypeRewriteRule moveField(@NotNull final DynamicOps ops, final Finder sourceFinder = parsePath(sourcePath); parsePath(targetPath); // Parse and cache target path eagerly to validate the syntax early. - return dynamicTransform("moveField(" + sourcePath + " -> " + targetPath + ")", ops, dynamic -> { + final String ruleName = "moveField(" + sourcePath + " -> " + targetPath + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { final Dynamic value = sourceFinder.get(dynamic); if (value == null) { return dynamic; // Source doesn't exist, nothing to move @@ -1630,6 +1904,7 @@ public static TypeRewriteRule moveField(@NotNull final DynamicOps ops, Dynamic result = removeAtPath(dynamic, sourcePath); return setAtPath(result, targetPath, value); }); + return withFieldOps(base, List.of(FieldOperation.move(sourcePath, targetPath)), ruleName); } /** @@ -1669,7 +1944,8 @@ public static TypeRewriteRule copyField(@NotNull final DynamicOps ops, final Finder sourceFinder = parsePath(sourcePath); - return dynamicTransform("copyField(" + sourcePath + " -> " + targetPath + ")", ops, dynamic -> { + final String ruleName = "copyField(" + sourcePath + " -> " + targetPath + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { final Dynamic value = sourceFinder.get(dynamic); if (value == null) { return dynamic; // Source doesn't exist, nothing to copy @@ -1677,6 +1953,7 @@ public static TypeRewriteRule copyField(@NotNull final DynamicOps ops, return setAtPath(dynamic, targetPath, value); }); + return withFieldOps(base, List.of(FieldOperation.copy(sourcePath, targetPath)), ruleName); } // ==================== Path-Based Combinators ==================== @@ -1717,7 +1994,9 @@ public static TypeRewriteRule transformFieldAt(@NotNull final DynamicOps Preconditions.checkNotNull(transform, "transform must not be null"); final Finder finder = parsePath(path); - return updateAt("transformFieldAt(" + path + ")", ops, finder, transform); + final String ruleName = "transformFieldAt(" + path + ")"; + final TypeRewriteRule base = updateAt(ruleName, ops, finder, transform); + return withFieldOps(base, List.of(FieldOperation.transformPath(path)), ruleName); } /** @@ -1765,7 +2044,8 @@ public static TypeRewriteRule renameFieldAt(@NotNull final DynamicOps ops final String oldName = path.substring(lastDot + 1); final Finder parentFinder = parsePath(parentPath); - return dynamicTransform("renameFieldAt(" + path + " -> " + newName + ")", ops, dynamic -> { + final String ruleName = "renameFieldAt(" + path + " -> " + newName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { final Dynamic parent = parentFinder.get(dynamic); if (parent == null) { return dynamic; @@ -1782,6 +2062,7 @@ public static TypeRewriteRule renameFieldAt(@NotNull final DynamicOps ops final Dynamic updatedParent = typedParent.remove(oldName).set(newName, typedValue); return parentFinder.set(dynamic, updatedParent); }); + return withFieldOps(base, List.of(FieldOperation.renamePath(path, newName)), ruleName); } /** @@ -1812,8 +2093,10 @@ public static TypeRewriteRule removeFieldAt(@NotNull final DynamicOps ops Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(path, "path must not be null"); - return dynamicTransform("removeFieldAt(" + path + ")", ops, + final String ruleName = "removeFieldAt(" + path + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> removeAtPath(dynamic, path)); + return withFieldOps(base, List.of(FieldOperation.removePath(path)), ruleName); } /** @@ -1852,13 +2135,15 @@ public static TypeRewriteRule addFieldAt(@NotNull final DynamicOps ops, final Finder finder = parsePath(path); - return dynamicTransform("addFieldAt(" + path + ")", ops, dynamic -> { + final String ruleName = "addFieldAt(" + path + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { // Only add if field doesn't exist if (finder.get(dynamic) != null) { return dynamic; } return setAtPath(dynamic, path, defaultValue); }); + return withFieldOps(base, List.of(FieldOperation.addPath(path)), ruleName); } // ==================== Conditional Combinators ==================== @@ -1895,7 +2180,8 @@ public static TypeRewriteRule ifFieldExists(@NotNull final DynamicOps ops Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(rule, "rule must not be null"); - return new TypeRewriteRule() { + final String ruleName = "ifFieldExists(" + fieldName + ", " + rule + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @NotNull public Optional> rewrite(@NotNull final Type type, @@ -1915,10 +2201,12 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { - return "ifFieldExists(" + fieldName + ", " + rule + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "exists")), ruleName); } /** @@ -1950,7 +2238,8 @@ public static TypeRewriteRule ifFieldMissing(@NotNull final DynamicOps op Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(rule, "rule must not be null"); - return new TypeRewriteRule() { + final String ruleName = "ifFieldMissing(" + fieldName + ", " + rule + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @NotNull public Optional> rewrite(@NotNull final Type type, @@ -1970,10 +2259,12 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { - return "ifFieldMissing(" + fieldName + ", " + rule + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "missing")), ruleName); } /** @@ -2010,7 +2301,8 @@ public static TypeRewriteRule ifFieldEquals(@NotNull final DynamicOps Preconditions.checkNotNull(value, "value must not be null"); Preconditions.checkNotNull(rule, "rule must not be null"); - return new TypeRewriteRule() { + final String ruleName = "ifFieldEquals(" + fieldName + " == " + value + ", " + rule + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @NotNull public Optional> rewrite(@NotNull final Type type, @@ -2050,10 +2342,12 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { - return "ifFieldEquals(" + fieldName + " == " + value + ", " + rule + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "equals")), ruleName); } // ==================== Single-Pass Conditional Combinators ==================== @@ -2136,13 +2430,15 @@ public static TypeRewriteRule ifFieldExists( Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return dynamicTransform("ifFieldExists(" + fieldName + ")", ops, dynamic -> { + final String ruleName = "ifFieldExists(" + fieldName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; if (typedDynamic.get(fieldName) != null) { return transform.apply(typedDynamic); } return dynamic; }); + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "exists")), ruleName); } /** @@ -2181,13 +2477,15 @@ public static TypeRewriteRule ifFieldMissing( Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return dynamicTransform("ifFieldMissing(" + fieldName + ")", ops, dynamic -> { + final String ruleName = "ifFieldMissing(" + fieldName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; if (typedDynamic.get(fieldName) == null) { return transform.apply(typedDynamic); } return dynamic; }); + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "missing")), ruleName); } /** @@ -2231,7 +2529,8 @@ public static TypeRewriteRule ifFieldEquals( Preconditions.checkNotNull(value, "value must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return dynamicTransform("ifFieldEquals(" + fieldName + " == " + value + ")", ops, dynamic -> { + final String ruleName = "ifFieldEquals(" + fieldName + " == " + value + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; final Dynamic field = typedDynamic.get(fieldName); @@ -2246,6 +2545,7 @@ public static TypeRewriteRule ifFieldEquals( } return dynamic; }); + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "equals")), ruleName); } /** @@ -2275,7 +2575,44 @@ private static boolean matchesValue(@NotNull final Dynamic field, @Not return false; } - // ==================== Private Helpers ==================== + // ==================== Field-Aware Wrapper ==================== + + /** + * Wraps a {@link TypeRewriteRule} with field-level metadata. + * + * @param rule the rule to wrap, must not be {@code null} + * @param fieldOperations the field operations metadata, must not be {@code null} + * @param name the display name for the wrapped rule, must not be {@code null} + * @return a field-aware rule wrapping the delegate + * @since 1.0.0 + */ + @NotNull + private static TypeRewriteRule withFieldOps(@NotNull final TypeRewriteRule rule, + @NotNull final List fieldOperations, + @NotNull final String name) { + return new FieldAwareTypeRewriteRule(rule, fieldOperations, name); + } + + /** + * Collects field operation metadata from all child rules that implement {@link FieldAwareRule}. + * + *

    This is used by composition methods ({@link #seq}, {@link #seqAll}, {@link #choice}) + * to aggregate field-level metadata from their children into the composed rule.

    + * + * @param rules the child rules to inspect + * @return an unmodifiable list of aggregated field operations; empty if no children are field-aware + * @since 1.0.0 + */ + @NotNull + private static List collectFieldOperations(@NotNull final TypeRewriteRule... rules) { + final List result = new ArrayList<>(); + for (final TypeRewriteRule rule : rules) { + if (rule instanceof FieldAwareRule fieldAware) { + result.addAll(fieldAware.fieldOperations()); + } + } + return List.copyOf(result); + } /** * Parses a dot-notation path into a composed Finder. @@ -2298,6 +2635,8 @@ private static Finder parsePath(@NotNull final String path) { return PATH_CACHE.computeIfAbsent(path, Rules::parsePathInternal); } + // ==================== Private Helpers ==================== + /** * Internal method that parses a path without caching. Uses character-based parsing for better performance than * regex. @@ -2427,7 +2766,13 @@ private static String[] splitPath(@NotNull final String path) { } /** - * Recursively sets a value at a path, creating intermediate objects. + * Recursive helper for setAtPath that navigates the path segments and sets the value at the end. + * + * @param dynamic the current dynamic object, must not be {@code null} + * @param parts the path segments, must not be {@code null} + * @param index the current index in the path segments + * @param value the value to set at the target path, must not be {@code null} + * @return the updated dynamic with the value set at the target path */ @NotNull private static Dynamic setAtPathRecursive(@NotNull final Dynamic dynamic, @@ -2451,8 +2796,6 @@ private static Dynamic setAtPathRecursive(@NotNull final Dynamic return dynamic.set(part, updatedChild); } - // ==================== Noop and Debug ==================== - /** * Creates the identity rule. * @@ -2463,6 +2806,8 @@ public static TypeRewriteRule noop() { return TypeRewriteRule.identity(); } + // ==================== Noop and Debug ==================== + /** * Creates a rule that logs when applied using the default System.out logger. * @@ -2523,9 +2868,75 @@ public Optional> rewrite(@NotNull final Type type, @NotNull final Ty } @Override + @NotNull public String toString() { return "log(" + message + ", " + rule + ")"; } }; } + + /** + * A {@link TypeRewriteRule} wrapper that also implements {@link FieldAwareRule}, providing structured field-level + * metadata for diagnostic purposes. + * + *

    This wrapper is used internally by the field operation factory methods + * (e.g., {@link #renameField}, {@link #removeField}) to annotate the returned rules with metadata about which + * fields they affect.

    + * + * @param delegate the underlying rule that performs the actual rewrite logic, must not be {@code null} + * @param fieldOperations immutable list of field-level operation metadata describing which fields this rule + * affects, must not be {@code null} + * @param name display name for this rule, used in {@link #toString()} and diagnostic output, must not be + * {@code null} + * @since 1.0.0 + */ + private record FieldAwareTypeRewriteRule( + @NotNull TypeRewriteRule delegate, + @NotNull List fieldOperations, + @NotNull String name + ) implements TypeRewriteRule, FieldAwareRule { + + /** + * Creates a new field-aware rule wrapper. + * + * @param delegate the underlying rule to delegate rewrite logic to, must not be {@code null} + * @param fieldOperations the field-level operation metadata, must not be {@code null} + * @param name the display name for this rule, must not be {@code null} + * @throws NullPointerException if any argument is {@code null} + */ + private FieldAwareTypeRewriteRule { + Preconditions.checkNotNull(delegate, "delegate must not be null"); + Preconditions.checkNotNull(fieldOperations, "fieldOperations must not be null"); + Preconditions.checkNotNull(name, "name must not be null"); + fieldOperations = List.copyOf(fieldOperations); + } + + /** + * {@inheritDoc} + * + *

    Delegates to the underlying rule's rewrite logic.

    + * + * @param type the type descriptor of the input, must not be {@code null} + * @param input the typed value to potentially rewrite, must not be {@code null} + * @return an {@link Optional} containing the rewritten value if this rule applies, or {@link Optional#empty()} + * if the rule doesn't match; never {@code null} + */ + @Override + @NotNull + public Optional> rewrite(@NotNull final Type type, + @NotNull final Typed input) { + return this.delegate.rewrite(type, input); + } + + /** + * Returns the display name of this rule. + * + * @return the rule name, never {@code null} + */ + @Override + @NotNull + public String toString() { + return this.name; + } + } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/TypeRewriteRule.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/TypeRewriteRule.java index 541e117..d440bdd 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/TypeRewriteRule.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/TypeRewriteRule.java @@ -216,6 +216,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return name; } @@ -272,6 +273,7 @@ public Optional> rewrite(@NotNull final Type type, @NotNull final Ty } @Override + @NotNull public String toString() { return name + "[" + targetType.describe() + "]"; } @@ -530,6 +532,7 @@ public Optional> rewrite(@NotNull final Type type, } @Override + @NotNull public String toString() { return name; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java index 1c879e9..394a42c 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java @@ -34,6 +34,8 @@ * are composable and can be combined to create complex migrations. *
  • {@link de.splatgames.aether.datafixers.api.rewrite.Rules} - Factory class * providing common rewrite rule combinators for typical data transformations.
  • + *
  • {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} - Marker + * interface for rules that carry structured field-level metadata for diagnostics.
  • * * *

    Common Rules

    diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/type/Typed.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/type/Typed.java index a589532..ad33da0 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/type/Typed.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/type/Typed.java @@ -32,6 +32,7 @@ import de.splatgames.aether.datafixers.api.util.Pair; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -677,7 +678,7 @@ public DataResult> withChildren(@NotNull final DynamicOps ops, } @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -693,6 +694,7 @@ public int hashCode() { } @Override + @NotNull public String toString() { return "Typed{type=" + this.type.describe() + ", value=" + this.value + "}"; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Either.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Either.java index af53a78..991e069 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Either.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Either.java @@ -24,6 +24,7 @@ import com.google.common.base.Preconditions; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.Optional; @@ -621,7 +622,7 @@ public R orElseGet(@NotNull final Function other) { * @return {@code true} if the other object is a Left with an equal value */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -646,8 +647,8 @@ public int hashCode() { * * @return a string in the format "Left[value]" */ - @NotNull @Override + @NotNull public String toString() { return "Left[" + this.value + "]"; } @@ -898,7 +899,7 @@ public R orElseGet(@NotNull final Function other) { * @return {@code true} if the other object is a Right with an equal value */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -923,8 +924,8 @@ public int hashCode() { * * @return a string in the format "Right[value]" */ - @NotNull @Override + @NotNull public String toString() { return "Right[" + this.value + "]"; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Pair.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Pair.java index 458ad85..28086a0 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Pair.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Pair.java @@ -207,7 +207,7 @@ public Pair swap() { * @return {@code true} if the specified object is a pair with equal values */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { if (this == obj) { return true; } @@ -238,6 +238,7 @@ public int hashCode() { * @return a string representation of this pair */ @Override + @NotNull public String toString() { return "(" + this.first + ", " + this.second + ")"; } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Unit.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Unit.java index 6a4feeb..0ff495a 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Unit.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/util/Unit.java @@ -23,6 +23,7 @@ package de.splatgames.aether.datafixers.api.util; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.Serial; import java.io.Serializable; @@ -103,6 +104,7 @@ private Unit() { * @return the string {@code "Unit"}, never {@code null} */ @Override + @NotNull public String toString() { return "Unit"; } @@ -129,7 +131,7 @@ public int hashCode() { * @return {@code true} if the specified object is a Unit, {@code false} otherwise */ @Override - public boolean equals(final Object obj) { + public boolean equals(@Nullable final Object obj) { return obj instanceof Unit; } diff --git a/aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java b/aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java new file mode 100644 index 0000000..da244e5 --- /dev/null +++ b/aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.rewrite; + +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.optic.TestOps; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that {@link Rules} factory methods produce {@link FieldAwareRule} instances + * with correct field operation metadata, and that composition methods properly + * aggregate field operations from their children. + */ +@DisplayName("FieldAwareRule integration with Rules") +class FieldAwareRulesTest { + + private static final TestOps OPS = TestOps.INSTANCE; + + // ==================== Helper Methods ==================== + + /** + * Asserts that the given rule is a {@link FieldAwareRule} and returns it. + */ + private static FieldAwareRule assertFieldAware(final TypeRewriteRule rule) { + assertThat(rule).isInstanceOf(FieldAwareRule.class); + return (FieldAwareRule) rule; + } + + /** + * Asserts that the given rule is a {@link FieldAwareRule} with the expected + * number of field operations. + */ + private static List assertFieldOpsCount(final TypeRewriteRule rule, + final int expectedCount) { + final FieldAwareRule fieldAware = assertFieldAware(rule); + final List ops = fieldAware.fieldOperations(); + assertThat(ops).hasSize(expectedCount); + return ops; + } + + // ==================== Individual Field Rules ==================== + + @Nested + @DisplayName("Individual Field Rules") + class IndividualFieldRules { + + @Test + @DisplayName("renameField should produce FieldAwareRule with 1 RENAME operation") + void renameFieldShouldProduceRenameOperation() { + final TypeRewriteRule rule = Rules.renameField(OPS, "old", "new"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(0).fieldPath()).containsExactly("old"); + assertThat(ops.get(0).targetFieldName()).isEqualTo("new"); + } + + @Test + @DisplayName("removeField should produce FieldAwareRule with 1 REMOVE operation") + void removeFieldShouldProduceRemoveOperation() { + final TypeRewriteRule rule = Rules.removeField(OPS, "field"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.REMOVE); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("addField should produce FieldAwareRule with 1 ADD operation") + void addFieldShouldProduceAddOperation() { + final Dynamic defaultVal = new Dynamic<>(OPS, 42); + final TypeRewriteRule rule = Rules.addField(OPS, "field", defaultVal); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.ADD); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("transformField should produce FieldAwareRule with 1 TRANSFORM operation") + void transformFieldShouldProduceTransformOperation() { + final TypeRewriteRule rule = Rules.transformField(OPS, "field", + d -> d.createInt(d.asInt().result().orElse(0) + 1)); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.TRANSFORM); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("setField should produce FieldAwareRule with 1 SET operation") + void setFieldShouldProduceSetOperation() { + final Dynamic value = new Dynamic<>(OPS, "value"); + final TypeRewriteRule rule = Rules.setField(OPS, "field", value); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.SET); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("renameFields should produce FieldAwareRule with 2 RENAME operations") + void renameFieldsShouldProduceMultipleRenameOperations() { + final TypeRewriteRule rule = Rules.renameFields(OPS, Map.of("a", "b", "c", "d")); + + final List ops = assertFieldOpsCount(rule, 2); + assertThat(ops).allMatch(op -> op.operationType() == FieldOperationType.RENAME); + } + + @Test + @DisplayName("removeFields should produce FieldAwareRule with 2 REMOVE operations") + void removeFieldsShouldProduceMultipleRemoveOperations() { + final TypeRewriteRule rule = Rules.removeFields(OPS, "a", "b"); + + final List ops = assertFieldOpsCount(rule, 2); + assertThat(ops).allMatch(op -> op.operationType() == FieldOperationType.REMOVE); + } + + @Test + @DisplayName("moveField should produce FieldAwareRule with 1 MOVE operation") + void moveFieldShouldProduceMoveOperation() { + final TypeRewriteRule rule = Rules.moveField(OPS, "a", "b"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.MOVE); + assertThat(ops.get(0).fieldPath()).containsExactly("a"); + assertThat(ops.get(0).targetFieldName()).isEqualTo("b"); + } + + @Test + @DisplayName("copyField should produce FieldAwareRule with 1 COPY operation") + void copyFieldShouldProduceCopyOperation() { + final TypeRewriteRule rule = Rules.copyField(OPS, "a", "b"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.COPY); + assertThat(ops.get(0).fieldPath()).containsExactly("a"); + assertThat(ops.get(0).targetFieldName()).isEqualTo("b"); + } + + @Test + @DisplayName("groupFields should produce FieldAwareRule with 1 GROUP operation") + void groupFieldsShouldProduceGroupOperation() { + final TypeRewriteRule rule = Rules.groupFields(OPS, "pos", "x", "y"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.GROUP); + assertThat(ops.get(0).targetFieldName()).isEqualTo("pos"); + } + + @Test + @DisplayName("flattenField should produce FieldAwareRule with 1 FLATTEN operation") + void flattenFieldShouldProduceFlattenOperation() { + final TypeRewriteRule rule = Rules.flattenField(OPS, "pos"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.FLATTEN); + assertThat(ops.get(0).fieldPath()).containsExactly("pos"); + } + + @Test + @DisplayName("ifFieldExists (3-arg) should produce FieldAwareRule with 1 CONDITIONAL operation") + void ifFieldExistsShouldProduceConditionalOperation() { + final TypeRewriteRule someRule = Rules.removeField(OPS, "field"); + final TypeRewriteRule rule = Rules.ifFieldExists(OPS, "field", someRule); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + assertThat(ops.get(0).description()).isEqualTo("exists"); + } + + @Test + @DisplayName("ifFieldMissing (3-arg) should produce FieldAwareRule with 1 CONDITIONAL operation") + void ifFieldMissingShouldProduceConditionalOperation() { + final TypeRewriteRule someRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule rule = Rules.ifFieldMissing(OPS, "field", someRule); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + assertThat(ops.get(0).description()).isEqualTo("missing"); + } + + @Test + @DisplayName("ifFieldEquals (4-arg) should produce FieldAwareRule with 1 CONDITIONAL operation") + void ifFieldEqualsShouldProduceConditionalOperation() { + final TypeRewriteRule someRule = Rules.removeField(OPS, "field"); + final TypeRewriteRule rule = Rules.ifFieldEquals(OPS, "field", 1, someRule); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + assertThat(ops.get(0).description()).isEqualTo("equals"); + } + } + + // ==================== Composition Aggregation ==================== + + @Nested + @DisplayName("Composition Aggregation") + class CompositionAggregation { + + @Test + @DisplayName("seq should aggregate field operations from children") + void seqShouldAggregateFieldOperations() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule addRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule composed = Rules.seq(renameRule, addRule); + + final List ops = assertFieldOpsCount(composed, 2); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(1).operationType()).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("seqAll should aggregate field operations from all children") + void seqAllShouldAggregateFieldOperations() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule removeRule = Rules.removeField(OPS, "deprecated"); + final TypeRewriteRule addRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule composed = Rules.seqAll(renameRule, removeRule, addRule); + + final List ops = assertFieldOpsCount(composed, 3); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(1).operationType()).isEqualTo(FieldOperationType.REMOVE); + assertThat(ops.get(2).operationType()).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("choice should aggregate field operations from children") + void choiceShouldAggregateFieldOperations() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule addRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule composed = Rules.choice(renameRule, addRule); + + final List ops = assertFieldOpsCount(composed, 2); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(1).operationType()).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("seq with identity should only include field-aware operations") + void seqWithIdentityShouldOnlyIncludeFieldAwareOps() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule composed = Rules.seq(renameRule, TypeRewriteRule.identity()); + + final List ops = assertFieldOpsCount(composed, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + } + + @Test + @DisplayName("seq of pure non-field-aware rules should not be FieldAwareRule") + void seqOfNonFieldAwareRulesShouldNotBeFieldAware() { + final TypeRewriteRule composed = Rules.seq( + TypeRewriteRule.identity(), + TypeRewriteRule.identity() + ); + + assertThat(composed).isNotInstanceOf(FieldAwareRule.class); + } + } + + // ==================== Batch Operations ==================== + + @Nested + @DisplayName("Batch Operations") + class BatchOperations { + + @Test + @DisplayName("batch should produce FieldAwareRule with aggregated operations") + void batchShouldProduceFieldAwareRuleWithAggregatedOps() { + final TypeRewriteRule rule = Rules.batch(OPS, b -> b + .rename("a", "b") + .remove("c") + ); + + final List ops = assertFieldOpsCount(rule, 2); + assertThat(ops).extracting(FieldOperation::operationType) + .containsExactlyInAnyOrder(FieldOperationType.RENAME, FieldOperationType.REMOVE); + } + } +} diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/AetherCli.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/AetherCli.java index 57c390f..01688a6 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/AetherCli.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/AetherCli.java @@ -25,6 +25,7 @@ import de.splatgames.aether.datafixers.cli.command.InfoCommand; import de.splatgames.aether.datafixers.cli.command.MigrateCommand; import de.splatgames.aether.datafixers.cli.command.ValidateCommand; +import org.jetbrains.annotations.NotNull; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.HelpCommand; @@ -99,7 +100,7 @@ public class AetherCli implements Callable { * but should not be {@code null} * @see CommandLine#execute(String...) */ - public static void main(final String[] args) { + public static void main(@NotNull final String[] args) { final int exitCode = new CommandLine(new AetherCli()) .setCaseInsensitiveEnumValuesAllowed(true) .execute(args); @@ -121,6 +122,7 @@ public static void main(final String[] args) { * @see CommandLine#usage(Object, java.io.PrintStream) */ @Override + @NotNull public Integer call() { // Print help when no subcommand is specified CommandLine.usage(this, System.out); diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/bootstrap/BootstrapLoadException.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/bootstrap/BootstrapLoadException.java index ea5b06a..4062e63 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/bootstrap/BootstrapLoadException.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/bootstrap/BootstrapLoadException.java @@ -22,6 +22,8 @@ package de.splatgames.aether.datafixers.cli.bootstrap; +import org.jetbrains.annotations.Nullable; + /** * Exception thrown when loading a {@link de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap} * implementation fails. @@ -34,19 +36,19 @@ public class BootstrapLoadException extends RuntimeException { /** * Constructs a new bootstrap load exception with the specified message. * - * @param message the detail message + * @param message the detail message, may be {@code null} */ - public BootstrapLoadException(final String message) { + public BootstrapLoadException(@Nullable final String message) { super(message); } /** * Constructs a new bootstrap load exception with the specified message and cause. * - * @param message the detail message - * @param cause the cause of this exception + * @param message the detail message, may be {@code null} + * @param cause the cause of this exception, may be {@code null} */ - public BootstrapLoadException(final String message, final Throwable cause) { + public BootstrapLoadException(@Nullable final String message, @Nullable final Throwable cause) { super(message, cause); } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/InfoCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/InfoCommand.java index b10ad61..1893e10 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/InfoCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/InfoCommand.java @@ -28,6 +28,7 @@ import de.splatgames.aether.datafixers.cli.format.FormatRegistry; import de.splatgames.aether.datafixers.core.AetherDataFixer; import de.splatgames.aether.datafixers.core.bootstrap.DataFixerRuntimeFactory; +import org.jetbrains.annotations.NotNull; import picocli.CommandLine.Command; import picocli.CommandLine.Option; @@ -171,6 +172,7 @@ public class InfoCommand implements Callable { * @see BootstrapLoader#load(String) */ @Override + @NotNull public Integer call() { System.out.println("Aether Datafixers CLI v0.3.0"); System.out.println("============================"); diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java index 8a1e3cd..5ccffd6 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java @@ -28,6 +28,9 @@ import de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap; import de.splatgames.aether.datafixers.api.dynamic.Dynamic; import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import de.splatgames.aether.datafixers.cli.bootstrap.BootstrapLoader; import de.splatgames.aether.datafixers.cli.format.FormatHandler; import de.splatgames.aether.datafixers.cli.format.FormatRegistry; @@ -36,6 +39,7 @@ import de.splatgames.aether.datafixers.core.AetherDataFixer; import de.splatgames.aether.datafixers.core.bootstrap.DataFixerRuntimeFactory; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; @@ -46,6 +50,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; @@ -396,6 +401,32 @@ public class MigrateCommand implements Callable { ) private boolean verbose; + /** + * Whether to enable field-level diagnostics output. + * + *

    When {@code true}, a detailed diagnostic report is generated for each + * migrated file, including information about every fix execution, rule + * application, and field-level operation (renames, removals, additions, + * transforms, etc.).

    + * + *

    Diagnostics output is written alongside the migration report: to + * {@link #reportFile} if specified, or to stderr otherwise. The output + * format follows the selected {@link #reportFormat}.

    + * + *

    Default value: {@code false}

    + * + *

    CLI usage: {@code --diagnostics}

    + * + * @see DiagnosticContext + * @see de.splatgames.aether.datafixers.api.diagnostic.FieldOperation + * @since 1.0.0 + */ + @Option( + names = {"--diagnostics"}, + description = "Enable field-level diagnostics output showing detailed fix and field operation information." + ) + private boolean diagnostics; + /** * Whether to pretty-print the output JSON. * @@ -440,6 +471,7 @@ public class MigrateCommand implements Callable { * @see #processFile(File, AetherDataFixer, FormatHandler, TypeReference, DataVersion) */ @Override + @NotNull public Integer call() { try { // 0. Validate version range @@ -474,6 +506,8 @@ public Integer call() { int errorCount = 0; final StringBuilder reportBuilder = new StringBuilder(); + final StringBuilder diagnosticBuilder = new StringBuilder(); + for (final File inputFile : this.inputFiles) { try { final MigrationResult result = processFile( @@ -483,6 +517,9 @@ public Integer call() { if (this.generateReport) { reportBuilder.append(result.report).append("\n"); } + if (this.diagnostics && !result.diagnosticOutput.isEmpty()) { + diagnosticBuilder.append(result.diagnosticOutput).append("\n"); + } } catch (final Exception e) { errorCount++; System.err.println("Error processing " + inputFile + ": " + e.getMessage()); @@ -505,6 +542,19 @@ public Integer call() { } } + // Write diagnostic output + if (this.diagnostics && !diagnosticBuilder.isEmpty()) { + final String diagnosticContent = diagnosticBuilder.toString(); + if (this.reportFile != null) { + Files.writeString(this.reportFile.toPath(), diagnosticContent, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + } else { + System.err.println(diagnosticContent); + } + } + // Summary if (this.inputFiles.size() > 1 || this.verbose) { System.err.println("Completed: " + successCount + " migrated, " + errorCount + " errors"); @@ -590,15 +640,24 @@ private MigrationResult processFile( System.err.println("Skipping " + inputFile + " (already at v" + sourceVersion.getVersion() + ")"); } - return new MigrationResult("", Duration.ZERO); + return new MigrationResult("", Duration.ZERO, ""); } // Create dynamic and migrate final Dynamic dynamic = new Dynamic<>(handler.ops(), data); final TaggedDynamic tagged = new TaggedDynamic(typeRef, dynamic); - // Perform migration - final TaggedDynamic migrated = fixer.update(tagged, sourceVersion, targetVersion); + // Perform migration (with diagnostics if enabled) + final DiagnosticContext diagCtx = this.diagnostics + ? DiagnosticContext.create(DiagnosticOptions.builder() + .captureSnapshots(false) + .captureRuleDetails(true) + .captureFieldDetails(true) + .build()) + : null; + final TaggedDynamic migrated = diagCtx != null + ? fixer.update(tagged, sourceVersion, targetVersion, diagCtx) + : fixer.update(tagged, sourceVersion, targetVersion); // Extract result @SuppressWarnings("unchecked") @@ -631,7 +690,19 @@ private MigrationResult processFile( ); } - return new MigrationResult(report, duration); + // Generate diagnostic report + final MigrationReport diagnosticReport = diagCtx != null ? diagCtx.getReport() : null; + String diagnosticOutput = ""; + if (diagnosticReport != null) { + final ReportFormatter formatter = ReportFormatter.forFormat(this.reportFormat); + diagnosticOutput = formatter.formatDiagnostic( + inputFile.getName(), + typeRef.getId(), + diagnosticReport + ); + } + + return new MigrationResult(report, duration, diagnosticOutput); } /** @@ -719,6 +790,21 @@ private void writeOutput(@NotNull final File inputFile, @NotNull final String co * @see #processFile(File, AetherDataFixer, FormatHandler, TypeReference, DataVersion) * @see ReportFormatter */ - private record MigrationResult(String report, Duration duration) { + /** + * Holds the result of a single file migration operation. + * + *

    This record captures the outcome of migrating one file, including + * the formatted report string (if reporting is enabled), the time + * taken for the migration, and the optional diagnostic output (if + * {@code --diagnostics} was enabled).

    + * + * @param report the formatted migration report string, empty if reporting is disabled + * or if the file was skipped (already at target version) + * @param duration the time elapsed during the migration process, including file I/O + * @param diagnosticOutput the formatted diagnostic report string, empty if diagnostics are disabled + * @see #processFile(File, AetherDataFixer, FormatHandler, TypeReference, DataVersion) + * @see ReportFormatter + */ + private record MigrationResult(String report, Duration duration, String diagnosticOutput) { } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java index ba9788a..bf47f5f 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java @@ -30,6 +30,7 @@ import de.splatgames.aether.datafixers.cli.util.VersionExtractor; import de.splatgames.aether.datafixers.core.AetherDataFixer; import de.splatgames.aether.datafixers.core.bootstrap.DataFixerRuntimeFactory; +import org.jetbrains.annotations.NotNull; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; @@ -237,6 +238,7 @@ public class ValidateCommand implements Callable { * @see #validateFile(File, FormatHandler, DataVersion) */ @Override + @NotNull public Integer call() { if (this.toVersion < 0) { System.err.println("Error: --to version must be non-negative, got: " + this.toVersion); diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/format/FormatParseException.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/format/FormatParseException.java index 621f933..e81ec61 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/format/FormatParseException.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/format/FormatParseException.java @@ -22,6 +22,8 @@ package de.splatgames.aether.datafixers.cli.format; +import org.jetbrains.annotations.Nullable; + /** * Exception thrown when parsing input data fails. * @@ -33,19 +35,19 @@ public class FormatParseException extends RuntimeException { /** * Constructs a new format parse exception with the specified message. * - * @param message the detail message + * @param message the detail message, may be {@code null} */ - public FormatParseException(final String message) { + public FormatParseException(@Nullable final String message) { super(message); } /** * Constructs a new format parse exception with the specified message and cause. * - * @param message the detail message - * @param cause the cause of this exception + * @param message the detail message, may be {@code null} + * @param cause the cause of this exception, may be {@code null} */ - public FormatParseException(final String message, final Throwable cause) { + public FormatParseException(@Nullable final String message, @Nullable final Throwable cause) { super(message, cause); } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java index 34d57b9..65782a7 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java @@ -25,7 +25,12 @@ import com.google.common.base.Preconditions; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FixExecution; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -102,13 +107,11 @@ public class JsonReportFormatter implements ReportFormatter { */ @Override @NotNull - public String formatSimple( - @NotNull final String fileName, - @NotNull final String type, - final int fromVersion, - final int toVersion, - @NotNull final Duration duration - ) { + public String formatSimple(@NotNull final String fileName, + @NotNull final String type, + final int fromVersion, + final int toVersion, + @NotNull final Duration duration) { Preconditions.checkNotNull(fileName, "fileName must not be null"); Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(duration, "duration must not be null"); @@ -122,4 +125,112 @@ public String formatSimple( return GSON.toJson(json); } + + /** + * Formats a diagnostic migration report as a JSON object. + * + *

    The output is a pretty-printed JSON object with the following structure:

    + *
    {@code
    +     * {
    +     *   "file": "player.json",
    +     *   "type": "player",
    +     *   "fromVersion": 100,
    +     *   "toVersion": 200,
    +     *   "durationMs": 42,
    +     *   "fixCount": 2,
    +     *   "ruleCount": 3,
    +     *   "fieldOperationCount": 5,
    +     *   "fixes": [
    +     *     {
    +     *       "name": "PlayerV1ToV2Fix",
    +     *       "fromVersion": 100,
    +     *       "toVersion": 150,
    +     *       "durationMs": 20,
    +     *       "rules": [
    +     *         {
    +     *           "name": "renameField",
    +     *           "matched": true,
    +     *           "durationMs": 5,
    +     *           "fieldOperations": [
    +     *             {
    +     *               "type": "RENAME",
    +     *               "field": "oldName",
    +     *               "target": "newName"
    +     *             }
    +     *           ]
    +     *         }
    +     *       ]
    +     *     }
    +     *   ],
    +     *   "warnings": []
    +     * }
    +     * }
    + * + * @param fileName the name of the migrated file + * @param type the type reference ID (e.g., "player", "world") + * @param report the diagnostic migration report containing fix and field operation details + * @return a pretty-printed JSON string representing the diagnostic report + * @since 1.0.0 + */ + @Override + @NotNull + public String formatDiagnostic(@NotNull final String fileName, + @NotNull final String type, + @NotNull final MigrationReport report) { + Preconditions.checkNotNull(fileName, "fileName must not be null"); + Preconditions.checkNotNull(type, "type must not be null"); + Preconditions.checkNotNull(report, "report must not be null"); + + final JsonObject json = new JsonObject(); + json.addProperty("file", fileName); + json.addProperty("type", type); + json.addProperty("fromVersion", report.fromVersion().getVersion()); + json.addProperty("toVersion", report.toVersion().getVersion()); + json.addProperty("durationMs", report.totalDuration().toMillis()); + json.addProperty("fixCount", report.fixCount()); + json.addProperty("ruleCount", report.ruleApplicationCount()); + json.addProperty("fieldOperationCount", report.totalFieldOperationCount()); + + final JsonArray fixes = new JsonArray(); + for (final FixExecution fix : report.fixExecutions()) { + final JsonObject fixObj = new JsonObject(); + fixObj.addProperty("name", fix.fixName()); + fixObj.addProperty("fromVersion", fix.fromVersion().getVersion()); + fixObj.addProperty("toVersion", fix.toVersion().getVersion()); + fixObj.addProperty("durationMs", fix.durationMillis()); + + final JsonArray rules = new JsonArray(); + for (final RuleApplication rule : fix.ruleApplications()) { + final JsonObject ruleObj = new JsonObject(); + ruleObj.addProperty("name", rule.ruleName()); + ruleObj.addProperty("matched", rule.matched()); + ruleObj.addProperty("durationMs", rule.durationMillis()); + + final JsonArray fieldOps = new JsonArray(); + for (final FieldOperation fieldOp : rule.fieldOperations()) { + final JsonObject opObj = new JsonObject(); + opObj.addProperty("type", fieldOp.operationType().name()); + opObj.addProperty("field", fieldOp.fieldPathString()); + fieldOp.targetFieldNameOpt() + .ifPresent(target -> opObj.addProperty("target", target)); + fieldOp.descriptionOpt() + .ifPresent(desc -> opObj.addProperty("description", desc)); + fieldOps.add(opObj); + } + ruleObj.add("fieldOperations", fieldOps); + rules.add(ruleObj); + } + fixObj.add("rules", rules); + fixes.add(fixObj); + } + json.add("fixes", fixes); + + final JsonArray warnings = new JsonArray(); + for (final String warning : report.warnings()) { + warnings.add(warning); + } + json.add("warnings", warnings); + + return GSON.toJson(json); + } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java index a83d2d8..3c9e04c 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java @@ -23,6 +23,7 @@ package de.splatgames.aether.datafixers.cli.report; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -64,13 +65,28 @@ public interface ReportFormatter { * @return the formatted report string */ @NotNull - String formatSimple( - @NotNull String fileName, - @NotNull String type, - int fromVersion, - int toVersion, - @NotNull Duration duration - ); + String formatSimple(@NotNull final String fileName, + @NotNull final String type, + final int fromVersion, + final int toVersion, + @NotNull final Duration duration); + + /** + * Formats a diagnostic migration report including field-level operation details. + * + *

    When diagnostics are enabled, the report includes comprehensive information + * about each fix execution and its field-level operations (renames, removals, + * additions, transforms, etc.).

    + * + * @param fileName the name of the migrated file + * @param type the type reference ID + * @param report the diagnostic migration report + * @return the formatted diagnostic report string + * @since 0.1.0 + */ + @NotNull + String formatDiagnostic(@NotNull final String fileName, + @NotNull final String type, @NotNull final MigrationReport report); /** * Gets a formatter by format name. @@ -82,9 +98,10 @@ String formatSimple( static ReportFormatter forFormat(@NotNull final String format) { Preconditions.checkNotNull(format, "format must not be null"); - return switch (format.toLowerCase()) { - case "json" -> new JsonReportFormatter(); - default -> new TextReportFormatter(); - }; + if (format.equalsIgnoreCase("json")) { + return new JsonReportFormatter(); + } else { + return new TextReportFormatter(); + } } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java index 54de636..11d3376 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java @@ -23,6 +23,10 @@ package de.splatgames.aether.datafixers.cli.report; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FixExecution; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -71,13 +75,11 @@ public class TextReportFormatter implements ReportFormatter { */ @Override @NotNull - public String formatSimple( - @NotNull final String fileName, - @NotNull final String type, - final int fromVersion, - final int toVersion, - @NotNull final Duration duration - ) { + public String formatSimple(@NotNull final String fileName, + @NotNull final String type, + final int fromVersion, + final int toVersion, + @NotNull final Duration duration) { Preconditions.checkNotNull(fileName, "fileName must not be null"); Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(duration, "duration must not be null"); @@ -85,4 +87,86 @@ public String formatSimple( return "Migration: " + fileName + " [" + type + "] v" + fromVersion + " -> v" + toVersion + " (" + duration.toMillis() + "ms)"; } + + /** + * Formats a diagnostic migration report as human-readable plain text. + * + *

    The output includes a summary line followed by per-fix details with + * field-level operations. Example output:

    + *
    +     * Diagnostic Report: player.json [player] v100 -> v200 (42ms)
    +     *   Fixes applied: 2, Rules: 3, Field operations: 5
    +     *
    +     *   Fix: PlayerV1ToV2Fix (v100 -> v150) 20ms
    +     *     Rule: renameField [matched] 5ms
    +     *       RENAME(oldName -> newName)
    +     *     Rule: addField [matched] 3ms
    +     *       ADD(health)
    +     *
    +     *   Fix: PlayerV2ToV3Fix (v150 -> v200) 22ms
    +     *     Rule: seq [matched] 10ms
    +     *       REMOVE(deprecated)
    +     *       TRANSFORM(stats)
    +     *       SET(version)
    +     * 
    + * + * @param fileName the name of the migrated file + * @param type the type reference ID (e.g., "player", "world") + * @param report the diagnostic migration report containing fix and field operation details + * @return a multi-line formatted diagnostic report string + * @since 1.0.0 + */ + @Override + @NotNull + public String formatDiagnostic(@NotNull final String fileName, + @NotNull final String type, + @NotNull final MigrationReport report) { + Preconditions.checkNotNull(fileName, "fileName must not be null"); + Preconditions.checkNotNull(type, "type must not be null"); + Preconditions.checkNotNull(report, "report must not be null"); + + final StringBuilder sb = new StringBuilder(); + + // Header + sb.append(String.format("Diagnostic Report: %s [%s] v%d -> v%d (%dms)%n", + fileName, type, + report.fromVersion().getVersion(), + report.toVersion().getVersion(), + report.totalDuration().toMillis())); + + sb.append(String.format(" Fixes applied: %d, Rules: %d, Field operations: %d%n", + report.fixCount(), + report.ruleApplicationCount(), + report.totalFieldOperationCount())); + + // Per-fix details + for (final FixExecution fix : report.fixExecutions()) { + sb.append(String.format("%n Fix: %s (v%d -> v%d) %dms%n", + fix.fixName(), + fix.fromVersion().getVersion(), + fix.toVersion().getVersion(), + fix.durationMillis())); + + for (final RuleApplication rule : fix.ruleApplications()) { + sb.append(String.format(" Rule: %s [%s] %dms%n", + rule.ruleName(), + rule.matched() ? "matched" : "skipped", + rule.durationMillis())); + + for (final FieldOperation fieldOp : rule.fieldOperations()) { + sb.append(" ").append(fieldOp.toSummary()).append('\n'); + } + } + } + + // Warnings + if (report.hasWarnings()) { + sb.append("\n Warnings:\n"); + for (final String warning : report.warnings()) { + sb.append(" ! ").append(warning).append('\n'); + } + } + + return sb.toString(); + } } diff --git a/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java b/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java new file mode 100644 index 0000000..9419b02 --- /dev/null +++ b/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.cli.report; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for the {@code formatDiagnostic} method in {@link TextReportFormatter} + * and {@link JsonReportFormatter}. + * + * @author Erik Pförtner + * @since 1.0.0 + */ +@DisplayName("Diagnostic Report Formatting") +class DiagnosticFormatterTest { + + /** + * Creates a minimal diagnostic report for testing using the DiagnosticContext builder. + * + * @param fieldOps the field operations to include in the rule application + * @return a migration report with one fix and one rule + */ + private static MigrationReport createTestReport(final List fieldOps) { + final DiagnosticContext ctx = DiagnosticContext.create( + DiagnosticOptions.builder() + .captureSnapshots(false) + .captureRuleDetails(true) + .captureFieldDetails(true) + .build() + ); + + final TypeReference type = new TypeReference("player"); + final DataVersion from = new DataVersion(100); + final DataVersion to = new DataVersion(200); + + final MigrationReport.Builder builder = ctx.reportBuilder(); + builder.startMigration(type, from, to); + + // Simulate a fix with a single rule application + final de.splatgames.aether.datafixers.api.fix.DataFix fakeFix = + new FakeDataFix("TestFix", from, to); + + builder.startFix(fakeFix); + builder.recordRuleApplication(new RuleApplication( + "renameField", "player", Instant.now(), + Duration.ofMillis(5), true, null, fieldOps + )); + builder.endFix(fakeFix, Duration.ofMillis(20), null); + + return builder.build(); + } + + /** + * Minimal DataFix implementation for test purposes. + */ + private static final class FakeDataFix implements de.splatgames.aether.datafixers.api.fix.DataFix { + + private final String name; + private final DataVersion fromVersion; + private final DataVersion toVersion; + + FakeDataFix(final String name, final DataVersion from, final DataVersion to) { + this.name = name; + this.fromVersion = from; + this.toVersion = to; + } + + @Override + public String name() { + return this.name; + } + + @Override + public DataVersion fromVersion() { + return this.fromVersion; + } + + @Override + public DataVersion toVersion() { + return this.toVersion; + } + + @Override + public de.splatgames.aether.datafixers.api.dynamic.Dynamic apply( + final de.splatgames.aether.datafixers.api.TypeReference type, + final de.splatgames.aether.datafixers.api.dynamic.Dynamic input, + final de.splatgames.aether.datafixers.api.fix.DataFixerContext context + ) { + return input; + } + } + + @Nested + @DisplayName("TextReportFormatter.formatDiagnostic()") + class TextFormat { + + private final TextReportFormatter formatter = new TextReportFormatter(); + + @Test + @DisplayName("includes diagnostic report header") + void includesHeader() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Diagnostic Report:"); + assertThat(result).contains("player.json"); + assertThat(result).contains("[player]"); + assertThat(result).contains("v100"); + assertThat(result).contains("v200"); + } + + @Test + @DisplayName("includes fix details") + void includesFixDetails() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Fix: TestFix"); + assertThat(result).contains("Rule: renameField"); + assertThat(result).contains("[matched]"); + } + + @Test + @DisplayName("includes field operation summaries") + void includesFieldOperations() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"), + FieldOperation.remove("deprecated"), + FieldOperation.add("health") + )); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("RENAME(oldName -> newName)"); + assertThat(result).contains("REMOVE(deprecated)"); + assertThat(result).contains("ADD(health)"); + } + + @Test + @DisplayName("includes counts summary") + void includesCountsSummary() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("a", "b"), + FieldOperation.remove("c") + )); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Fixes applied: 1"); + assertThat(result).contains("Rules: 1"); + assertThat(result).contains("Field operations: 2"); + } + + @Test + @DisplayName("handles empty field operations") + void handlesEmptyFieldOps() { + final MigrationReport report = createTestReport(List.of()); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Field operations: 0"); + } + + @Test + @DisplayName("rejects null arguments") + void rejectsNullArguments() { + final MigrationReport report = createTestReport(List.of()); + + assertThatThrownBy(() -> formatter.formatDiagnostic(null, "player", report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", null, report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", "player", null)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("JsonReportFormatter.formatDiagnostic()") + class JsonFormat { + + private final JsonReportFormatter formatter = new JsonReportFormatter(); + + @Test + @DisplayName("produces valid JSON") + void producesValidJson() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + assertThat(json).isNotNull(); + } + + @Test + @DisplayName("includes all top-level fields") + void includesTopLevelFields() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + + assertThat(json.get("file").getAsString()).isEqualTo("player.json"); + assertThat(json.get("type").getAsString()).isEqualTo("player"); + assertThat(json.get("fromVersion").getAsInt()).isEqualTo(100); + assertThat(json.get("toVersion").getAsInt()).isEqualTo(200); + assertThat(json.get("fixCount").getAsInt()).isEqualTo(1); + assertThat(json.get("ruleCount").getAsInt()).isEqualTo(1); + assertThat(json.get("fieldOperationCount").getAsInt()).isEqualTo(1); + } + + @Test + @DisplayName("includes field operations in rules") + void includesFieldOperations() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"), + FieldOperation.remove("deprecated") + )); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + + final var fixes = json.getAsJsonArray("fixes"); + assertThat(fixes).hasSize(1); + + final var rules = fixes.get(0).getAsJsonObject().getAsJsonArray("rules"); + assertThat(rules).hasSize(1); + + final var fieldOps = rules.get(0).getAsJsonObject().getAsJsonArray("fieldOperations"); + assertThat(fieldOps).hasSize(2); + + final var renameOp = fieldOps.get(0).getAsJsonObject(); + assertThat(renameOp.get("type").getAsString()).isEqualTo("RENAME"); + assertThat(renameOp.get("field").getAsString()).isEqualTo("oldName"); + assertThat(renameOp.get("target").getAsString()).isEqualTo("newName"); + + final var removeOp = fieldOps.get(1).getAsJsonObject(); + assertThat(removeOp.get("type").getAsString()).isEqualTo("REMOVE"); + assertThat(removeOp.get("field").getAsString()).isEqualTo("deprecated"); + } + + @Test + @DisplayName("includes warnings array") + void includesWarnings() { + final MigrationReport report = createTestReport(List.of()); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + + assertThat(json.getAsJsonArray("warnings")).isNotNull(); + } + + @Test + @DisplayName("rejects null arguments") + void rejectsNullArguments() { + final MigrationReport report = createTestReport(List.of()); + + assertThatThrownBy(() -> formatter.formatDiagnostic(null, "player", report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", null, report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", "player", null)) + .isInstanceOf(NullPointerException.class); + } + } +} diff --git a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/gson/GsonOps.java b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/gson/GsonOps.java index 02a4ad6..3e98a8c 100644 --- a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/gson/GsonOps.java +++ b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/gson/GsonOps.java @@ -1133,6 +1133,7 @@ public JsonElement convertTo(@NotNull final DynamicOps sourceOps, * @return the string {@code "GsonOps"}; never {@code null} */ @Override + @NotNull public String toString() { return "GsonOps"; } diff --git a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/jackson/JacksonJsonOps.java b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/jackson/JacksonJsonOps.java index d331805..6d04393 100644 --- a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/jackson/JacksonJsonOps.java +++ b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/json/jackson/JacksonJsonOps.java @@ -1371,6 +1371,7 @@ public JsonNode convertTo(@NotNull final DynamicOps sourceOps, * @return the string {@code "JacksonJsonOps"}; never {@code null} */ @Override + @NotNull public String toString() { return "JacksonJsonOps"; } diff --git a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/toml/jackson/JacksonTomlOps.java b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/toml/jackson/JacksonTomlOps.java index e6acde5..e676eec 100644 --- a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/toml/jackson/JacksonTomlOps.java +++ b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/toml/jackson/JacksonTomlOps.java @@ -1406,6 +1406,7 @@ public JsonNode convertTo(@NotNull final DynamicOps sourceOps, * @return the string {@code "JacksonTomlOps"}; never {@code null} */ @Override + @NotNull public String toString() { return "JacksonTomlOps"; } diff --git a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/xml/jackson/JacksonXmlOps.java b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/xml/jackson/JacksonXmlOps.java index 6b7a920..e23a22f 100644 --- a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/xml/jackson/JacksonXmlOps.java +++ b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/xml/jackson/JacksonXmlOps.java @@ -1590,6 +1590,7 @@ public static void validateForSerialization(@NotNull final JsonNode node) { * @return the string {@code "JacksonXmlOps"}; never {@code null} */ @Override + @NotNull public String toString() { return "JacksonXmlOps"; } diff --git a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/jackson/JacksonYamlOps.java b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/jackson/JacksonYamlOps.java index 8756b7d..ed7c48d 100644 --- a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/jackson/JacksonYamlOps.java +++ b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/jackson/JacksonYamlOps.java @@ -1392,6 +1392,7 @@ public JsonNode convertTo(@NotNull final DynamicOps sourceOps, * @return the string {@code "JacksonYamlOps"}; never {@code null} */ @Override + @NotNull public String toString() { return "JacksonYamlOps"; } diff --git a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOps.java b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOps.java index 72c97d4..7bc804f 100644 --- a/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOps.java +++ b/aether-datafixers-codec/src/main/java/de/splatgames/aether/datafixers/codec/yaml/snakeyaml/SnakeYamlOps.java @@ -1316,6 +1316,7 @@ private Object deepCopy(@Nullable final Object value, final int depth) { * @return the string {@code "SnakeYamlOps"}; never {@code null} */ @Override + @NotNull public String toString() { return "SnakeYamlOps"; } diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java index 32f4c50..296f431 100644 --- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java +++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java @@ -31,6 +31,7 @@ import de.splatgames.aether.datafixers.api.exception.DecodeException; import de.splatgames.aether.datafixers.api.exception.EncodeException; import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.api.fix.DataFixerContext; import de.splatgames.aether.datafixers.api.schema.Schema; import de.splatgames.aether.datafixers.api.schema.SchemaRegistry; import de.splatgames.aether.datafixers.api.type.Type; @@ -41,8 +42,8 @@ * High-level facade for the Aether DataFixers system. * *

    {@code AetherDataFixer} provides a unified interface for encoding, decoding, - * and migrating data across versions. It combines a {@link SchemaRegistry} for - * type definitions with a {@link DataFixer} for version migrations.

    + * and migrating data across versions. It combines a {@link SchemaRegistry} for type definitions with a + * {@link DataFixer} for version migrations.

    * *

    Core Operations

    *