This is an automated email from the ASF dual-hosted git repository.

amogh-jahagirdar pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 69972dbe39 Core: V4 write direction wrappers (#16936)
69972dbe39 is described below

commit 69972dbe395a4bce97868ac78361ca4a1940c44e
Author: Steven Zhen Wu <[email protected]>
AuthorDate: Tue Oct 20 06:54:55 2026 -0700

    Core: V4 write direction wrappers (#16936)
    
    Add reusable write-direction wrappers that present a legacy DataFile, 
DeleteFile, or ManifestFile as a v4 TrackedFile row for manifest serialization. 
Content stats are served by a reusable map-backed view over the file's stat 
maps.
    
    Generated-by: Cursor Grok 4.6
    Co-authored-by: Cursor <[email protected]>
---
 .../main/java/org/apache/iceberg/ManifestFile.java |  20 +
 .../apache/iceberg/geospatial/GeospatialBound.java |  24 +-
 .../iceberg/geospatial/TestGeospatialBound.java    |  30 ++
 .../org/apache/iceberg/MapBackedContentStats.java  | 244 +++++++++
 .../org/apache/iceberg/TrackedFileAdapters.java    | 565 ++++++++++++++++++++-
 .../apache/iceberg/TestMapBackedContentStats.java  | 361 +++++++++++++
 .../apache/iceberg/TestTrackedFileAdapters.java    | 352 ++++++++++++-
 7 files changed, 1579 insertions(+), 17 deletions(-)

diff --git a/api/src/main/java/org/apache/iceberg/ManifestFile.java 
b/api/src/main/java/org/apache/iceberg/ManifestFile.java
index cfedb78a7c..32c7adae67 100644
--- a/api/src/main/java/org/apache/iceberg/ManifestFile.java
+++ b/api/src/main/java/org/apache/iceberg/ManifestFile.java
@@ -186,6 +186,26 @@ public interface ManifestFile {
   /** Returns the total number of rows in all files with status DELETED in the 
manifest file. */
   Long deletedRowsCount();
 
+  /** Returns the number of files with status REPLACED in the manifest file. */
+  default Integer replacedFilesCount() {
+    return 0;
+  }
+
+  /** Returns the total number of rows in all files with status REPLACED in 
the manifest file. */
+  default Long replacedRowsCount() {
+    return 0L;
+  }
+
+  /** Returns the number of files with status MODIFIED in the manifest file. */
+  default Integer modifiedFilesCount() {
+    return 0;
+  }
+
+  /** Returns the total number of rows in all files with status MODIFIED in 
the manifest file. */
+  default Long modifiedRowsCount() {
+    return 0L;
+  }
+
   /**
    * Returns a list of {@link PartitionFieldSummary partition field summaries}.
    *
diff --git 
a/api/src/main/java/org/apache/iceberg/geospatial/GeospatialBound.java 
b/api/src/main/java/org/apache/iceberg/geospatial/GeospatialBound.java
index 3425a46638..cdb76ae3c4 100644
--- a/api/src/main/java/org/apache/iceberg/geospatial/GeospatialBound.java
+++ b/api/src/main/java/org/apache/iceberg/geospatial/GeospatialBound.java
@@ -21,6 +21,7 @@ package org.apache.iceberg.geospatial;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.util.Objects;
+import org.apache.iceberg.StructLike;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 
 /**
@@ -44,7 +45,7 @@ import 
org.apache.iceberg.relocated.com.google.common.base.Preconditions;
  * <p>This class represents a lower or upper geospatial bound and handles 
serialization and
  * deserialization of these bounds to/from byte arrays, conforming to the 
Iceberg specification.
  */
-public class GeospatialBound {
+public class GeospatialBound implements StructLike {
   /**
    * Parses a geospatial bound from a byte buffer according to Iceberg spec.
    *
@@ -282,6 +283,27 @@ public class GeospatialBound {
     return !Double.isNaN(m);
   }
 
+  @Override
+  public int size() {
+    return 4;
+  }
+
+  @Override
+  public <T> T get(int pos, Class<T> javaClass) {
+    return switch (pos) {
+      case 0 -> javaClass.cast(x);
+      case 1 -> javaClass.cast(y);
+      case 2 -> hasZ() ? javaClass.cast(z) : null;
+      case 3 -> hasM() ? javaClass.cast(m) : null;
+      default -> throw new IllegalArgumentException("Invalid position: " + 
pos);
+    };
+  }
+
+  @Override
+  public <T> void set(int pos, T value) {
+    throw new UnsupportedOperationException("GeospatialBound is read only");
+  }
+
   @Override
   public String toString() {
     return "GeospatialBound(" + simpleString() + ")";
diff --git 
a/api/src/test/java/org/apache/iceberg/geospatial/TestGeospatialBound.java 
b/api/src/test/java/org/apache/iceberg/geospatial/TestGeospatialBound.java
index 8a6a6325cc..7b60ddc38c 100644
--- a/api/src/test/java/org/apache/iceberg/geospatial/TestGeospatialBound.java
+++ b/api/src/test/java/org/apache/iceberg/geospatial/TestGeospatialBound.java
@@ -19,9 +19,11 @@
 package org.apache.iceberg.geospatial;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import org.apache.iceberg.StructLike;
 import org.apache.iceberg.util.ByteBuffers;
 import org.junit.jupiter.api.Test;
 
@@ -71,6 +73,34 @@ public class TestGeospatialBound {
     assertThat(bound.hasM()).isTrue();
   }
 
+  @Test
+  public void testStructLikeXY() {
+    StructLike bound = GeospatialBound.createXY(1.0, 2.0);
+    assertThat(bound.size()).isEqualTo(4);
+    assertThat(bound.get(0, Double.class)).isEqualTo(1.0);
+    assertThat(bound.get(1, Double.class)).isEqualTo(2.0);
+    assertThat(bound.get(2, Double.class)).isNull();
+    assertThat(bound.get(3, Double.class)).isNull();
+  }
+
+  @Test
+  public void testStructLikeXYZM() {
+    StructLike bound = GeospatialBound.createXYZM(3.0, 4.0, 5.0, 6.0);
+    assertThat(bound.size()).isEqualTo(4);
+    assertThat(bound.get(0, Double.class)).isEqualTo(3.0);
+    assertThat(bound.get(1, Double.class)).isEqualTo(4.0);
+    assertThat(bound.get(2, Double.class)).isEqualTo(5.0);
+    assertThat(bound.get(3, Double.class)).isEqualTo(6.0);
+  }
+
+  @Test
+  public void testSet() {
+    GeospatialBound bound = GeospatialBound.createXY(1.0, 2.0);
+    assertThatThrownBy(() -> bound.set(0, 9.0))
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("GeospatialBound is read only");
+  }
+
   @Test
   public void testEqualsAndHashCode() {
     GeospatialBound xy1 = GeospatialBound.createXY(1.0, 2.0);
diff --git a/core/src/main/java/org/apache/iceberg/MapBackedContentStats.java 
b/core/src/main/java/org/apache/iceberg/MapBackedContentStats.java
new file mode 100644
index 0000000000..66e9b6694f
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/MapBackedContentStats.java
@@ -0,0 +1,244 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg;
+
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.relocated.com.google.common.collect.Sets;
+import org.apache.iceberg.types.Conversions;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.Types;
+
+/** Reusable {@link ContentStats} view over a {@link ContentFile}'s stat maps. 
*/
+class MapBackedContentStats implements ContentStats {
+  private final Schema tableSchema;
+  private final Map<Integer, FieldStats<?>> statsById = Maps.newHashMap();
+
+  private Types.StructType type;
+  private Map<Integer, Long> valueCounts;
+  private Map<Integer, Long> nullValueCounts;
+  private Map<Integer, Long> nanValueCounts;
+  private Map<Integer, Integer> avgValueSizes;
+  private Map<Integer, ByteBuffer> lowerBounds;
+  private Map<Integer, ByteBuffer> upperBounds;
+
+  MapBackedContentStats(Schema tableSchema) {
+    Preconditions.checkArgument(tableSchema != null, "Invalid table schema: 
null");
+    this.tableSchema = tableSchema;
+  }
+
+  MapBackedContentStats wrap(ContentFile<?> file) {
+    this.valueCounts = file.valueCounts();
+    this.nullValueCounts = file.nullValueCounts();
+    this.nanValueCounts = file.nanValueCounts();
+    this.avgValueSizes = file.avgValueSizes();
+    this.lowerBounds = file.lowerBounds();
+    this.upperBounds = file.upperBounds();
+    this.type = null;
+    return this;
+  }
+
+  @Override
+  public Iterable<FieldStats<?>> fieldStats() {
+    return Iterables.filter(Iterables.transform(statsFieldIds(), 
this::statsFor), Objects::nonNull);
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public <T> FieldStats<T> statsFor(int fieldId) {
+    // Schema is fixed for this instance; wrap() rebinds the metric maps. An 
id absent
+    // from the current maps is left uncached so a later file can still 
surface it.
+    if (!containsFieldInMaps(fieldId)) {
+      return null;
+    }
+
+    if (!statsById.containsKey(fieldId)) {
+      statsById.put(fieldId, createFieldStats(fieldId));
+    }
+
+    return (FieldStats<T>) statsById.get(fieldId);
+  }
+
+  private FieldStats<?> createFieldStats(int fieldId) {
+    Types.NestedField field = tableSchema.findField(fieldId);
+    // A file can carry metrics for an id this schema does not have. Skip it.
+    if (field == null) {
+      return null;
+    }
+
+    Type fieldType = field.type();
+    Types.StructType struct =
+        StatsUtil.fieldStatsStruct(fieldType, StatsUtil.toBaseId(fieldId), 
MetricsModes.Full.get());
+    // null means a struct, list, or map, or an id outside the stats window. 
The schema is bound
+    // once during wrapper creation, so the cached null stays valid across 
wrap().
+    if (struct == null) {
+      return null;
+    }
+
+    return new MapBackedFieldStats<>(fieldId, fieldType, struct);
+  }
+
+  @Override
+  public Types.StructType type() {
+    if (type == null) {
+      this.type = StatsUtil.statsReadSchema(tableSchema, statsFieldIds());
+    }
+
+    return type;
+  }
+
+  @Override
+  public ContentStats copy() {
+    throw new UnsupportedOperationException("copy is not implemented");
+  }
+
+  @Override
+  public ContentStats copy(Set<Integer> fieldIds) {
+    throw new UnsupportedOperationException("copy is not implemented");
+  }
+
+  private boolean containsFieldInMaps(int fieldId) {
+    return containsId(valueCounts, fieldId)
+        || containsId(nullValueCounts, fieldId)
+        || containsId(nanValueCounts, fieldId)
+        || containsId(avgValueSizes, fieldId)
+        || containsId(lowerBounds, fieldId)
+        || containsId(upperBounds, fieldId);
+  }
+
+  private Set<Integer> statsFieldIds() {
+    Set<Integer> ids =
+        Sets.newHashSetWithExpectedSize(valueCounts == null ? 0 : 
valueCounts.size());
+    addKeys(ids, valueCounts);
+    addKeys(ids, nullValueCounts);
+    addKeys(ids, nanValueCounts);
+    addKeys(ids, avgValueSizes);
+    addKeys(ids, lowerBounds);
+    addKeys(ids, upperBounds);
+    return ids;
+  }
+
+  private static void addKeys(Set<Integer> ids, Map<Integer, ?> map) {
+    if (map != null) {
+      ids.addAll(map.keySet());
+    }
+  }
+
+  private static boolean containsId(Map<Integer, ?> map, int id) {
+    return map != null && map.containsKey(id);
+  }
+
+  /** Reusable {@link FieldStats} view over one field's entries in a {@link 
ContentFile}'s maps. */
+  private class MapBackedFieldStats<T> implements FieldStats<T> {
+    private final int fieldId;
+    private final Types.StructType struct;
+    private final Type fieldType;
+    private final Type boundType;
+
+    private MapBackedFieldStats(int fieldId, Type fieldType, Types.StructType 
struct) {
+      this.fieldId = fieldId;
+      this.fieldType = fieldType;
+      this.struct = struct;
+      this.boundType = struct.fieldType(StatsUtil.LOWER_BOUND_NAME);
+    }
+
+    @Override
+    public int fieldId() {
+      return fieldId;
+    }
+
+    @Override
+    public Types.StructType type() {
+      return struct;
+    }
+
+    @Override
+    public T lowerBound() {
+      return decodeBound(lowerBounds);
+    }
+
+    @Override
+    public T upperBound() {
+      return decodeBound(upperBounds);
+    }
+
+    @SuppressWarnings("unchecked")
+    private T decodeBound(Map<Integer, ByteBuffer> bounds) {
+      if (boundType == null || bounds == null) {
+        return null;
+      }
+
+      return (T) Conversions.fromByteBuffer(fieldType, bounds.get(fieldId));
+    }
+
+    @Override
+    public boolean tightBounds() {
+      return false;
+    }
+
+    @Override
+    public boolean hasValueCount() {
+      return count(valueCounts) != null;
+    }
+
+    @Override
+    public long valueCount() {
+      return count(valueCounts);
+    }
+
+    @Override
+    public boolean hasNullValueCount() {
+      return count(nullValueCounts) != null;
+    }
+
+    @Override
+    public long nullValueCount() {
+      return count(nullValueCounts);
+    }
+
+    @Override
+    public boolean hasNanValueCount() {
+      return count(nanValueCounts) != null;
+    }
+
+    @Override
+    public long nanValueCount() {
+      return count(nanValueCounts);
+    }
+
+    @Override
+    public Integer avgValueSizeInBytes() {
+      return avgValueSizes == null ? null : avgValueSizes.get(fieldId);
+    }
+
+    @Override
+    public FieldStats<T> copy() {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+
+    private Long count(Map<Integer, Long> counts) {
+      return counts == null ? null : counts.get(fieldId);
+    }
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java 
b/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
index 9e7b808c77..cd3c573968 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
@@ -22,10 +22,14 @@ import java.io.Serializable;
 import java.nio.ByteBuffer;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 
-/** Adapts {@link TrackedFile} entries to their read APIs, for example {@link 
DataFile}. */
+/**
+ * Adapts between the {@link TrackedFile} model and the {@link DataFile} / 
{@link DeleteFile} /
+ * {@link ManifestFile} APIs in both directions.
+ */
 class TrackedFileAdapters {
 
   private TrackedFileAdapters() {}
@@ -55,6 +59,16 @@ class TrackedFileAdapters {
     return new TrackedManifestFile(file);
   }
 
+  /** Returns a reusable adapter from {@link DataFile} to {@link TrackedFile}. 
*/
+  static DataTrackedFile forDataFile(Schema tableSchema) {
+    return new DataTrackedFile(tableSchema);
+  }
+
+  /** Returns a reusable adapter from {@link ManifestFile} to {@link 
TrackedFile}. */
+  static ManifestTrackedFile forManifestFile() {
+    return new ManifestTrackedFile();
+  }
+
   /** Shared base for data and delete file adapters. */
   private abstract static class TrackedFileAdapter<F extends ContentFile<F>>
       implements ContentFile<F>, Serializable {
@@ -378,6 +392,10 @@ class TrackedFileAdapters {
       this.file = file;
     }
 
+    private TrackedFile file() {
+      return file;
+    }
+
     @Override
     public String path() {
       return file.location();
@@ -452,6 +470,26 @@ class TrackedFileAdapters {
       return file.manifestInfo().deletedRowsCount();
     }
 
+    @Override
+    public Integer replacedFilesCount() {
+      return file.manifestInfo().replacedFilesCount();
+    }
+
+    @Override
+    public Long replacedRowsCount() {
+      return file.manifestInfo().replacedRowsCount();
+    }
+
+    @Override
+    public Integer modifiedFilesCount() {
+      return file.manifestInfo().modifiedFilesCount();
+    }
+
+    @Override
+    public Long modifiedRowsCount() {
+      return file.manifestInfo().modifiedRowsCount();
+    }
+
     @Override
     public List<PartitionFieldSummary> partitions() {
       return null;
@@ -483,6 +521,531 @@ class TrackedFileAdapters {
     }
   }
 
+  /** Adapts a {@link DataFile} to {@link TrackedFile}. */
+  static class DataTrackedFile implements TrackedFile {
+    private final MapBackedContentStats statsWrapper;
+    private final WrappedEntryTracking trackingWrapper = new 
WrappedEntryTracking();
+
+    private Tracking tracking;
+    private DataFile file;
+    private ContentStats stats;
+
+    DataTrackedFile(Schema tableSchema) {
+      this.statsWrapper = new MapBackedContentStats(tableSchema);
+    }
+
+    /**
+     * Re-points this adapter at a {@link DataFile} from the public API. 
Tracking is unset.
+     *
+     * <p>Returns the inner {@link TrackedFile} when {@code newFile} is a 
{@link TrackedDataFile}.
+     */
+    public TrackedFile wrap(DataFile newFile) {
+      if (newFile instanceof TrackedDataFile tracked) {
+        return tracked.file();
+      }
+
+      wrapInternal(newFile);
+      this.tracking = null;
+      return this;
+    }
+
+    /**
+     * Re-points this adapter at a {@link ManifestEntry}. Converts the 
contained data file and the
+     * entry's tracking fields.
+     *
+     * <p>Returns the inner {@link TrackedFile} when the entry's file is a 
{@link TrackedDataFile}.
+     */
+    public TrackedFile wrap(ManifestEntry<DataFile> entry) {
+      Preconditions.checkArgument(entry != null, "Invalid entry: null");
+      if (entry.file() instanceof TrackedDataFile tracked) {
+        return tracked.file();
+      }
+
+      wrapInternal(entry.file());
+      this.tracking = trackingWrapper.wrap(entry);
+      return this;
+    }
+
+    private void wrapInternal(DataFile newFile) {
+      Preconditions.checkArgument(newFile != null, "Invalid file: null");
+      Preconditions.checkArgument(
+          newFile.content() == FileContent.DATA,
+          "Invalid content for data file: %s",
+          newFile.content());
+
+      this.file = newFile;
+      this.stats = hasContentStats(newFile) ? statsWrapper.wrap(newFile) : 
null;
+    }
+
+    @Override
+    public Tracking tracking() {
+      return tracking;
+    }
+
+    @Override
+    public FileContent contentType() {
+      return FileContent.DATA;
+    }
+
+    @Override
+    public String location() {
+      return file.location();
+    }
+
+    @Override
+    public FileFormat fileFormat() {
+      return file.format();
+    }
+
+    @Override
+    public long recordCount() {
+      return file.recordCount();
+    }
+
+    @Override
+    public long fileSizeInBytes() {
+      return file.fileSizeInBytes();
+    }
+
+    @Override
+    public Integer specId() {
+      // Files in one manifest may use different specs; this is the spec for 
this data file only.
+      return file.specId();
+    }
+
+    @Override
+    public StructLike partition() {
+      return file.partition();
+    }
+
+    @Override
+    public ContentStats contentStats() {
+      return stats;
+    }
+
+    @Override
+    public Integer sortOrderId() {
+      return file.sortOrderId();
+    }
+
+    @Override
+    public DeletionVector deletionVector() {
+      return file.deletionVector();
+    }
+
+    @Override
+    public ManifestInfo manifestInfo() {
+      return null;
+    }
+
+    @Override
+    public ByteBuffer keyMetadata() {
+      return file.keyMetadata();
+    }
+
+    @Override
+    public List<Long> splitOffsets() {
+      return file.splitOffsets();
+    }
+
+    @Override
+    public TrackedFile copy() {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+
+    @Override
+    public TrackedFile copyWithStats(Set<Integer> requestedColumnIds) {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+  }
+
+  /** Adapts a {@link ManifestFile} to {@link TrackedFile}. */
+  static class ManifestTrackedFile implements TrackedFile {
+    private final WrappedManifestInfo manifestInfo = new WrappedManifestInfo();
+    private final WrappedManifestTracking trackingWrapper = new 
WrappedManifestTracking();
+    private Tracking tracking;
+    private ManifestFile manifest;
+    private long recordCount;
+    private FileContent contentType;
+
+    ManifestTrackedFile() {}
+
+    /**
+     * Re-points this adapter at {@code newManifest}. Converts the manifest's 
own fields only;
+     * write-time tracking updates are applied by the versioned writer.
+     *
+     * <p>Returns the inner {@link TrackedFile} when {@code newManifest} is a 
{@link
+     * TrackedManifestFile}.
+     */
+    public TrackedFile wrap(ManifestFile newManifest) {
+      if (newManifest instanceof TrackedManifestFile tracked) {
+        return tracked.file();
+      }
+
+      Preconditions.checkArgument(newManifest != null, "Invalid manifest file: 
null");
+
+      this.manifest = newManifest;
+      this.contentType =
+          newManifest.content() == ManifestContent.DATA
+              ? FileContent.DATA_MANIFEST
+              : FileContent.DELETE_MANIFEST;
+      this.recordCount = manifestRecordCount(newManifest);
+      this.tracking = trackingWrapper.wrap(newManifest);
+      this.manifestInfo.wrap(newManifest);
+      return this;
+    }
+
+    @Override
+    public Tracking tracking() {
+      return tracking;
+    }
+
+    @Override
+    public FileContent contentType() {
+      return contentType;
+    }
+
+    @Override
+    public String location() {
+      return manifest.path();
+    }
+
+    @Override
+    public FileFormat fileFormat() {
+      // Manifest files before v4 are always Avro.
+      return FileFormat.AVRO;
+    }
+
+    @Override
+    public long recordCount() {
+      // Number of TrackedFile rows stored in the manifest.
+      return recordCount;
+    }
+
+    @Override
+    public long fileSizeInBytes() {
+      return manifest.length();
+    }
+
+    @Override
+    public Integer specId() {
+      // Spec the wrapped manifest was written with. Data file entries in a v4 
manifest file may
+      // use different specs.
+      return manifest.partitionSpecId();
+    }
+
+    @Override
+    public StructLike partition() {
+      return null;
+    }
+
+    @Override
+    public ContentStats contentStats() {
+      return null;
+    }
+
+    @Override
+    public Integer sortOrderId() {
+      // Manifests have no table sort order.
+      return null;
+    }
+
+    @Override
+    public DeletionVector deletionVector() {
+      return null;
+    }
+
+    @Override
+    public ManifestInfo manifestInfo() {
+      return manifestInfo;
+    }
+
+    @Override
+    public ByteBuffer keyMetadata() {
+      return manifest.keyMetadata();
+    }
+
+    @Override
+    public List<Long> splitOffsets() {
+      return null;
+    }
+
+    @Override
+    public TrackedFile copy() {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+
+    @Override
+    public TrackedFile copyWithStats(Set<Integer> requestedColumnIds) {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+  }
+
+  /** Reusable {@link ManifestInfo} view over a {@link ManifestFile}'s counts. 
*/
+  private static class WrappedManifestInfo implements ManifestInfo {
+    private ManifestFile manifest = null;
+
+    WrappedManifestInfo wrap(ManifestFile newManifest) {
+      this.manifest = newManifest;
+      return this;
+    }
+
+    @Override
+    public int addedFilesCount() {
+      return manifest.addedFilesCount();
+    }
+
+    @Override
+    public int existingFilesCount() {
+      return manifest.existingFilesCount();
+    }
+
+    @Override
+    public int deletedFilesCount() {
+      return manifest.deletedFilesCount();
+    }
+
+    @Override
+    public int replacedFilesCount() {
+      return manifest.replacedFilesCount();
+    }
+
+    @Override
+    public int modifiedFilesCount() {
+      return manifest.modifiedFilesCount();
+    }
+
+    @Override
+    public long addedRowsCount() {
+      return manifest.addedRowsCount();
+    }
+
+    @Override
+    public long existingRowsCount() {
+      return manifest.existingRowsCount();
+    }
+
+    @Override
+    public long deletedRowsCount() {
+      return manifest.deletedRowsCount();
+    }
+
+    @Override
+    public long replacedRowsCount() {
+      return manifest.replacedRowsCount();
+    }
+
+    @Override
+    public long modifiedRowsCount() {
+      return manifest.modifiedRowsCount();
+    }
+
+    @Override
+    public long minSequenceNumber() {
+      return manifest.minSequenceNumber();
+    }
+
+    @Override
+    public ManifestBitmap manifestDeletionVector() {
+      return manifest.manifestDeletionVector();
+    }
+
+    @Override
+    public int formatVersion() {
+      return manifest.formatVersion();
+    }
+
+    @Override
+    public ManifestInfo copy() {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+  }
+
+  private static class WrappedEntryTracking implements Tracking {
+    private ManifestEntry<DataFile> entry = null;
+
+    WrappedEntryTracking wrap(ManifestEntry<DataFile> newEntry) {
+      this.entry = newEntry;
+      return this;
+    }
+
+    @Override
+    public EntryStatus status() {
+      return entryStatus(entry.status());
+    }
+
+    @Override
+    public Long snapshotId() {
+      return entry.snapshotId();
+    }
+
+    @Override
+    public Long dataSequenceNumber() {
+      return entry.dataSequenceNumber();
+    }
+
+    @Override
+    public Long fileSequenceNumber() {
+      return entry.fileSequenceNumber();
+    }
+
+    @Override
+    public Long modifiedSnapshotId() {
+      return null;
+    }
+
+    @Override
+    public Long firstRowId() {
+      return entry.file().firstRowId();
+    }
+
+    @Override
+    public ByteBuffer deletedPositions() {
+      return null;
+    }
+
+    @Override
+    public ByteBuffer replacedPositions() {
+      return null;
+    }
+
+    @Override
+    public String manifestLocation() {
+      return entry.file().manifestLocation();
+    }
+
+    @Override
+    public long manifestPos() {
+      Long pos = entry.file().pos();
+      return pos != null ? pos : -1L;
+    }
+
+    @Override
+    public Tracking copy() {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+  }
+
+  private static class WrappedManifestTracking implements Tracking {
+    private ManifestFile manifest = null;
+
+    WrappedManifestTracking wrap(ManifestFile newManifest) {
+      this.manifest = newManifest;
+      return this;
+    }
+
+    @Override
+    public EntryStatus status() {
+      // Pre-v4 manifests have no status and are live; the writer sets 
EXISTING or MODIFIED.
+      return EntryStatus.EXISTING;
+    }
+
+    @Override
+    public Long snapshotId() {
+      return manifest.snapshotId();
+    }
+
+    @Override
+    public Long dataSequenceNumber() {
+      return manifest.sequenceNumber();
+    }
+
+    @Override
+    public Long fileSequenceNumber() {
+      return manifest.sequenceNumber();
+    }
+
+    @Override
+    public Long modifiedSnapshotId() {
+      return null;
+    }
+
+    @Override
+    public Long firstRowId() {
+      return manifest.firstRowId();
+    }
+
+    @Override
+    public ByteBuffer deletedPositions() {
+      return null;
+    }
+
+    @Override
+    public ByteBuffer replacedPositions() {
+      return null;
+    }
+
+    @Override
+    public String manifestLocation() {
+      return null;
+    }
+
+    @Override
+    public long manifestPos() {
+      return -1L;
+    }
+
+    @Override
+    public Tracking copy() {
+      throw new UnsupportedOperationException("copy is not implemented");
+    }
+  }
+
+  private static EntryStatus entryStatus(ManifestEntry.Status status) {
+    return switch (status) {
+      case EXISTING -> EntryStatus.EXISTING;
+      case ADDED -> EntryStatus.ADDED;
+      case DELETED -> EntryStatus.DELETED;
+    };
+  }
+
+  private static boolean hasContentStats(ContentFile<?> file) {
+    return isPresent(file.valueCounts())
+        || isPresent(file.nullValueCounts())
+        || isPresent(file.nanValueCounts())
+        || isPresent(file.totalBytes())
+        || isPresent(file.lowerBounds())
+        || isPresent(file.upperBounds());
+  }
+
+  private static boolean isPresent(Map<?, ?> map) {
+    return map != null && !map.isEmpty();
+  }
+
+  /**
+   * Record count of a manifest is the number of TrackedFile rows it stores: 
the sum of per-status
+   * file counts. Missing counts fail rather than producing an incorrect total.
+   */
+  private static long manifestRecordCount(ManifestFile manifest) {
+    Preconditions.checkNotNull(
+        manifest.addedFilesCount(),
+        "Cannot convert manifest %s: missing added files count",
+        manifest.path());
+    Preconditions.checkNotNull(
+        manifest.existingFilesCount(),
+        "Cannot convert manifest %s: missing existing files count",
+        manifest.path());
+    Preconditions.checkNotNull(
+        manifest.deletedFilesCount(),
+        "Cannot convert manifest %s: missing deleted files count",
+        manifest.path());
+    Preconditions.checkArgument(
+        Objects.equals(manifest.replacedFilesCount(), 0),
+        "Cannot convert manifest %s: Invalid replaced file count: %s",
+        manifest.path(),
+        manifest.replacedFilesCount());
+    Preconditions.checkArgument(
+        Objects.equals(manifest.modifiedFilesCount(), 0),
+        "Cannot convert manifest %s: Invalid modified file count: %s",
+        manifest.path(),
+        manifest.modifiedFilesCount());
+    return (long) manifest.addedFilesCount()
+        + manifest.existingFilesCount()
+        + manifest.deletedFilesCount()
+        + manifest.replacedFilesCount()
+        + manifest.modifiedFilesCount();
+  }
+
   private static int resolveSpecId(TrackedFile file, Map<Integer, 
PartitionSpec> specsById) {
     Integer specId = file.specId();
     if (specId != null) {
diff --git 
a/core/src/test/java/org/apache/iceberg/TestMapBackedContentStats.java 
b/core/src/test/java/org/apache/iceberg/TestMapBackedContentStats.java
new file mode 100644
index 0000000000..fa5a8b2ab9
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestMapBackedContentStats.java
@@ -0,0 +1,361 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Stream;
+import org.apache.iceberg.geospatial.GeospatialBound;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.types.Comparators;
+import org.apache.iceberg.types.Conversions;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class TestMapBackedContentStats {
+
+  private static final Schema SCHEMA =
+      new Schema(
+          Types.NestedField.required(1, "id", Types.IntegerType.get()),
+          Types.NestedField.optional(2, "score", Types.FloatType.get()),
+          Types.NestedField.optional(3, "ts", Types.LongType.get()),
+          Types.NestedField.optional(4, "name", Types.StringType.get()),
+          Types.NestedField.optional(5, "flag", Types.BooleanType.get()));
+
+  private static final PartitionData EMPTY_PARTITION =
+      new PartitionData(PartitionSpec.unpartitioned().partitionType());
+
+  /**
+   * Stats on fields 1-4; field 5 is absent. Field 3 has no value-count entry, 
so valueCount()
+   * throws on unboxing.
+   */
+  private static final DataFile FILE_WITH_STATS =
+      dataFile(
+          100L,
+          ImmutableMap.of(1, 100L, 2, 100L, 4, 100L),
+          ImmutableMap.of(2, 5L, 3, 1L, 4, 2L),
+          ImmutableMap.of(2, 3L),
+          ImmutableMap.of(
+              1, buf(Types.IntegerType.get(), 1),
+              2, buf(Types.FloatType.get(), 1.5f),
+              3, buf(Types.LongType.get(), 100L),
+              4, buf(Types.StringType.get(), "aaa")),
+          ImmutableMap.of(
+              1, buf(Types.IntegerType.get(), 1000),
+              2, buf(Types.FloatType.get(), 9.5f),
+              3, buf(Types.LongType.get(), 999L),
+              4, buf(Types.StringType.get(), "zzz")));
+
+  @Test
+  void typeMatchesIdsPresentInMaps() {
+    MapBackedContentStats stats = new MapBackedContentStats(SCHEMA);
+
+    assertThat(stats.type().fields()).isEmpty();
+
+    stats.wrap(FILE_WITH_STATS);
+
+    assertThat(stats.type().fields())
+        .containsExactlyInAnyOrderElementsOf(
+            StatsUtil.statsReadSchema(SCHEMA, List.of(1, 2, 3, 4)).fields());
+  }
+
+  @ParameterizedTest
+  @MethodSource("typesAndBounds")
+  void boundDecodingPerType(Type type, Object lower, Object upper) {
+    int fieldId = 1;
+    Schema schema = new Schema(Types.NestedField.optional(fieldId, "col", 
type));
+    DataFile file =
+        dataFile(
+            100L,
+            ImmutableMap.of(fieldId, 1L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            bound(fieldId, Conversions.toByteBuffer(type, lower)),
+            bound(fieldId, Conversions.toByteBuffer(type, upper)));
+    FieldStats<?> stats = new 
MapBackedContentStats(schema).wrap(file).statsFor(fieldId);
+
+    Comparator<Object> comparator = 
Comparators.forType(type.asPrimitiveType());
+    
assertThat(stats.lowerBound()).usingComparator(comparator).isEqualTo(lower);
+    
assertThat(stats.upperBound()).usingComparator(comparator).isEqualTo(upper);
+  }
+
+  private static Stream<Arguments> typesAndBounds() {
+    return Stream.of(
+        Arguments.of(Types.BooleanType.get(), false, true),
+        Arguments.of(Types.IntegerType.get(), -5, 100),
+        Arguments.of(Types.LongType.get(), 0L, 1_000L),
+        Arguments.of(Types.FloatType.get(), 1.5f, 9.5f),
+        Arguments.of(Types.DoubleType.get(), 0.0d, 25.0d),
+        Arguments.of(Types.DateType.get(), 100, 200),
+        Arguments.of(Types.TimeType.get(), 1_000L, 2_000L),
+        Arguments.of(Types.TimestampType.withZone(), 111L, 222L),
+        Arguments.of(Types.TimestampNanoType.withZone(), 111L, 222L),
+        Arguments.of(Types.StringType.get(), "a", "z"),
+        Arguments.of(
+            Types.UUIDType.get(),
+            UUID.fromString("07ceab48-62b2-4219-9172-856e687c92ad"),
+            UUID.fromString("a9a7c24d-2869-4c66-8803-b1f6a36256ed")),
+        Arguments.of(
+            Types.FixedType.ofLength(4),
+            ByteBuffer.wrap(new byte[] {0, 1, 2, 3}),
+            ByteBuffer.wrap(new byte[] {4, 5, 6, 7})),
+        Arguments.of(
+            Types.BinaryType.get(),
+            ByteBuffer.wrap(new byte[] {1, 2}),
+            ByteBuffer.wrap(new byte[] {3, 4, 5})),
+        Arguments.of(Types.DecimalType.of(9, 2), new BigDecimal("1.23"), new 
BigDecimal("9.99")),
+        Arguments.of(Types.UnknownType.get(), null, null));
+  }
+
+  @Test
+  void geoBoundsDecode() {
+    GeospatialBound lower = GeospatialBound.createXY(1.0, 2.0);
+    GeospatialBound upper = GeospatialBound.createXYZM(3.0, 4.0, 5.0, 6.0);
+    Schema schema = new Schema(Types.NestedField.optional(10, "geom", 
Types.GeometryType.crs84()));
+    DataFile file =
+        dataFile(
+            100L,
+            ImmutableMap.of(10, 26L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of(10, lower.toByteBuffer()),
+            ImmutableMap.of(10, upper.toByteBuffer()));
+    MapBackedContentStats stats = new MapBackedContentStats(schema).wrap(file);
+
+    FieldStats<?> geom = stats.statsFor(10);
+    
assertThat(geom.lowerBound()).isInstanceOf(GeospatialBound.class).isEqualTo(lower);
+    
assertThat(geom.upperBound()).isInstanceOf(GeospatialBound.class).isEqualTo(upper);
+  }
+
+  @Test
+  void missingBoundsDecodeToNull() {
+    DataFile file =
+        dataFile(
+            100L,
+            ImmutableMap.of(1, 100L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of());
+    MapBackedContentStats stats = new MapBackedContentStats(SCHEMA).wrap(file);
+
+    FieldStats<?> id = stats.statsFor(1);
+    assertThat(id.lowerBound()).isNull();
+    assertThat(id.upperBound()).isNull();
+    assertThat(id.valueCount()).isEqualTo(100L);
+  }
+
+  @Test
+  void countsAndTightBounds() {
+    MapBackedContentStats stats = new 
MapBackedContentStats(SCHEMA).wrap(FILE_WITH_STATS);
+
+    FieldStats<?> id = stats.statsFor(1);
+    assertThat(id.hasValueCount()).isTrue();
+    assertThat(id.valueCount()).isEqualTo(100L);
+    assertThat(id.hasNullValueCount()).isFalse();
+    assertThat(id.hasNanValueCount()).isFalse();
+    assertThatThrownBy(id::nullValueCount)
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("Long.longValue()");
+    assertThatThrownBy(id::nanValueCount)
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("Long.longValue()");
+    // ContentFile maps have no tight-bounds flag so the view always reports 
false.
+    assertThat(id.tightBounds()).isFalse();
+    // FILE_WITH_STATS has no avg-size map.
+    assertThat(id.avgValueSizeInBytes()).isNull();
+
+    FieldStats<?> score = stats.statsFor(2);
+    assertThat(score.hasNullValueCount()).isTrue();
+    assertThat(score.nullValueCount()).isEqualTo(5L);
+    assertThat(score.hasNanValueCount()).isTrue();
+    assertThat(score.nanValueCount()).isEqualTo(3L);
+
+    FieldStats<?> ts = stats.statsFor(3);
+    assertThat(ts.hasValueCount()).isFalse();
+    assertThatThrownBy(ts::valueCount)
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("Long.longValue()");
+    assertThat(ts.hasNullValueCount()).isTrue();
+    assertThat(ts.nullValueCount()).isEqualTo(1L);
+
+    FieldStats<?> name = stats.statsFor(4);
+    assertThat(name.hasValueCount()).isTrue();
+    assertThat(name.valueCount()).isEqualTo(100L);
+    assertThat(name.hasNullValueCount()).isTrue();
+    assertThat(name.nullValueCount()).isEqualTo(2L);
+    assertThat(name.hasNanValueCount()).isFalse();
+    assertThatThrownBy(name::nanValueCount)
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("Long.longValue()");
+  }
+
+  @Test
+  void fieldWithoutStatsIsExcluded() {
+    MapBackedContentStats stats = new 
MapBackedContentStats(SCHEMA).wrap(FILE_WITH_STATS);
+    assertThat(stats.type().field(StatsUtil.toBaseId(5))).isNull();
+    assertThat(stats.statsFor(5)).isNull();
+  }
+
+  @Test
+  void unknownFieldIdInMaps() {
+    DataFile file =
+        dataFile(
+            100L,
+            ImmutableMap.of(99, 1L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of());
+    MapBackedContentStats stats = new MapBackedContentStats(SCHEMA).wrap(file);
+
+    assertThat(stats.statsFor(99)).isNull();
+    assertThat(stats.type().fields()).isEmpty();
+    assertThat(stats.fieldStats()).isEmpty();
+  }
+
+  @Test
+  void wrapInvalidatesType() {
+    MapBackedContentStats stats = new 
MapBackedContentStats(SCHEMA).wrap(FILE_WITH_STATS);
+    Types.StructType firstType = stats.type();
+    assertThat(firstType.fields())
+        .containsExactlyInAnyOrderElementsOf(
+            StatsUtil.statsReadSchema(SCHEMA, List.of(1, 2, 3, 4)).fields());
+
+    DataFile file2 =
+        dataFile(
+            100L,
+            ImmutableMap.of(1, 50L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of(1, buf(Types.IntegerType.get(), 500)),
+            ImmutableMap.of(1, buf(Types.IntegerType.get(), 5000)));
+    stats.wrap(file2);
+
+    assertThat(stats.type().fields())
+        .containsExactlyInAnyOrderElementsOf(
+            StatsUtil.statsReadSchema(SCHEMA, List.of(1)).fields());
+    assertThat(stats.type()).isNotEqualTo(firstType);
+  }
+
+  @Test
+  void reuseRebindsFields() {
+    MapBackedContentStats stats = new MapBackedContentStats(SCHEMA);
+
+    stats.wrap(FILE_WITH_STATS);
+    assertThat(stats.statsFor(1).lowerBound()).isEqualTo(1);
+    assertThat(stats.statsFor(1).upperBound()).isEqualTo(1000);
+    assertThat(stats.statsFor(1).valueCount()).isEqualTo(100L);
+    assertThat(stats.statsFor(2)).isNotNull();
+    assertThat(stats.statsFor(5)).isNull();
+
+    DataFile file2 =
+        dataFile(
+            100L,
+            ImmutableMap.of(1, 50L, 5, 40L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of(
+                1, buf(Types.IntegerType.get(), 500), 5, 
buf(Types.BooleanType.get(), false)),
+            ImmutableMap.of(
+                1, buf(Types.IntegerType.get(), 5000), 5, 
buf(Types.BooleanType.get(), true)));
+    stats.wrap(file2);
+
+    assertThat(stats.statsFor(1).lowerBound()).isEqualTo(500);
+    assertThat(stats.statsFor(1).upperBound()).isEqualTo(5000);
+    assertThat(stats.statsFor(1).valueCount()).isEqualTo(50L);
+    assertThat(stats.statsFor(2)).isNull();
+    FieldStats<?> flag = stats.statsFor(5);
+    assertThat(flag).isNotNull();
+    assertThat(flag.lowerBound()).isEqualTo(false);
+    assertThat(flag.upperBound()).isEqualTo(true);
+    assertThat(flag.valueCount()).isEqualTo(40L);
+  }
+
+  @Test
+  void listElementFieldStats() {
+    Schema schema =
+        new Schema(
+            Types.NestedField.required(
+                1, "nums", Types.ListType.ofRequired(2, 
Types.IntegerType.get())));
+    DataFile file =
+        dataFile(
+            100L,
+            ImmutableMap.of(2, 4L),
+            ImmutableMap.of(),
+            ImmutableMap.of(),
+            ImmutableMap.of(2, buf(Types.IntegerType.get(), 1)),
+            ImmutableMap.of(2, buf(Types.IntegerType.get(), 9)));
+    MapBackedContentStats stats = new MapBackedContentStats(schema).wrap(file);
+
+    FieldStats<?> element = stats.statsFor(2);
+    assertThat(element.lowerBound()).isEqualTo(1);
+    assertThat(element.upperBound()).isEqualTo(9);
+    
assertThat(stats.fieldStats()).extracting(FieldStats::fieldId).containsExactly(2);
+  }
+
+  private static ByteBuffer buf(Type type, Object value) {
+    return Conversions.toByteBuffer(type, value);
+  }
+
+  private static Map<Integer, ByteBuffer> bound(int fieldId, ByteBuffer value) 
{
+    return value == null ? ImmutableMap.of() : ImmutableMap.of(fieldId, value);
+  }
+
+  private static DataFile dataFile(
+      long recordCount,
+      Map<Integer, Long> valueCounts,
+      Map<Integer, Long> nullValueCounts,
+      Map<Integer, Long> nanValueCounts,
+      Map<Integer, ByteBuffer> lowerBounds,
+      Map<Integer, ByteBuffer> upperBounds) {
+    Metrics metrics =
+        new Metrics(
+            recordCount,
+            null,
+            valueCounts,
+            nullValueCounts,
+            nanValueCounts,
+            lowerBounds,
+            upperBounds);
+    return new GenericDataFile(
+        PartitionSpec.unpartitioned().specId(),
+        "s3://bucket/data/file.parquet",
+        FileFormat.PARQUET,
+        EMPTY_PARTITION,
+        1024L,
+        metrics,
+        null,
+        ImmutableList.of(0L),
+        null,
+        null);
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java 
b/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
index 22f980d93b..1d72a884e7 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
@@ -29,6 +29,7 @@ import java.util.Map;
 import org.apache.iceberg.mumbling.MumblingTestUtil;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.types.Comparators;
 import org.apache.iceberg.types.Conversions;
 import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.Test;
@@ -50,6 +51,17 @@ class TestTrackedFileAdapters {
   private static final long DATA_SEQUENCE_NUMBER = 10L;
   private static final long FILE_SEQUENCE_NUMBER = 11L;
   private static final long FIRST_ROW_ID = 1000L;
+  private static final int SORT_ORDER_ID = 3;
+
+  private static final long MANIFEST_SEQUENCE_NUMBER = 5L;
+  private static final long MANIFEST_MIN_SEQUENCE_NUMBER = 4L;
+  private static final int ADDED_FILES_COUNT = 2;
+  private static final long ADDED_ROWS_COUNT = 200L;
+  private static final int EXISTING_FILES_COUNT = 3;
+  private static final long EXISTING_ROWS_COUNT = 300L;
+  private static final int DELETED_FILES_COUNT = 1;
+  private static final long DELETED_ROWS_COUNT = 100L;
+  private static final int NON_ZERO_FILE_COUNT = 1;
 
   private static final int UNPARTITIONED_SPEC_ID = 
PartitionSpec.unpartitioned().specId();
   private static final Map<Integer, PartitionSpec> UNPARTITIONED =
@@ -70,6 +82,9 @@ class TestTrackedFileAdapters {
   // these are populated by readers using the setter with the position of the 
field.
   private static final int MANIFEST_LOCATION_ORDINAL = 
Tracking.schema().fields().size();
   private static final int MANIFEST_POSITION_ORDINAL = 
Tracking.schema().fields().size() + 1;
+  // row_position follows the data file schema and stores the manifest 
position.
+  private static final int DATA_FILE_POS_ORDINAL =
+      DataFile.getType(Types.StructType.of()).fields().size();
 
   private static final Schema TABLE_SCHEMA =
       new Schema(
@@ -96,6 +111,8 @@ class TestTrackedFileAdapters {
     CONTENT_STATS.setStats(3, GEOM_STATS);
   }
 
+  private static final PartitionSpec UNPARTITIONED_SPEC = 
PartitionSpec.unpartitioned();
+
   private static final Tracking MANIFEST_TRACKING =
       new TrackingStruct(
           EntryStatus.ADDED,
@@ -114,18 +131,58 @@ class TestTrackedFileAdapters {
           .addedFilesCount(3)
           .existingFilesCount(5)
           .deletedFilesCount(2)
-          .replacedFilesCount(0)
-          .modifiedFilesCount(0)
+          .replacedFilesCount(4)
+          .modifiedFilesCount(1)
           .addedRowsCount(300L)
           .existingRowsCount(500L)
           .deletedRowsCount(200L)
-          .replacedRowsCount(0L)
-          .modifiedRowsCount(0L)
+          .replacedRowsCount(40L)
+          .modifiedRowsCount(10L)
           .minSequenceNumber(7L)
           .dv(ByteBuffer.wrap(MumblingTestUtil.onlyFirstBitSetBytes()))
           .formatVersion(FORMAT_VERSION_V4)
           .build();
 
+  private static final Metrics METRICS_WITH_BOUNDS =
+      new Metrics(
+          100L,
+          ImmutableMap.of(1, 16L, 2, 64L),
+          ImmutableMap.of(1, 100L, 2, 100L),
+          ImmutableMap.of(1, 0L, 2, 5L),
+          ImmutableMap.of(),
+          ImmutableMap.of(1, Conversions.toByteBuffer(Types.IntegerType.get(), 
1)),
+          ImmutableMap.of(1, Conversions.toByteBuffer(Types.IntegerType.get(), 
1000)));
+
+  private static final DataFile DATA_FILE =
+      new GenericDataFile(
+          UNPARTITIONED_SPEC.specId(),
+          DATA_FILE_LOCATION,
+          FileFormat.PARQUET,
+          PartitionData.EMPTY,
+          1024L,
+          new Metrics(100L),
+          KEY_METADATA,
+          ImmutableList.of(0L),
+          SORT_ORDER_ID,
+          FIRST_ROW_ID);
+  private static final DataFile DATA_FILE_WITH_METRICS =
+      new GenericDataFile(
+          UNPARTITIONED_SPEC.specId(),
+          DATA_FILE_LOCATION,
+          FileFormat.PARQUET,
+          PartitionData.EMPTY,
+          1024L,
+          METRICS_WITH_BOUNDS,
+          KEY_METADATA,
+          ImmutableList.of(0L),
+          SORT_ORDER_ID,
+          FIRST_ROW_ID);
+
+  static {
+    assignManifestPosition(DATA_FILE, MANIFEST_LOCATION, MANIFEST_POS);
+    assignManifestPosition(DATA_FILE_WITH_METRICS, MANIFEST_LOCATION, 
MANIFEST_POS);
+  }
+
   @Test
   void dataFileAdapterDelegation() {
     TrackingStruct tracking =
@@ -198,7 +255,7 @@ class TestTrackedFileAdapters {
   @ParameterizedTest
   @EnumSource(value = FileContent.class, mode = EnumSource.Mode.EXCLUDE, names 
= "DATA")
   void dataFileAdapterRejectsNonDataContent(FileContent contentType) {
-    TrackedFileStruct file = dummyTrackedFile(contentType);
+    TrackedFileStruct file = trackedFile(contentType);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asDataFile(file, 
UNPARTITIONED))
         .isInstanceOf(IllegalArgumentException.class)
@@ -380,7 +437,7 @@ class TestTrackedFileAdapters {
   @ParameterizedTest
   @EnumSource(value = FileContent.class, mode = EnumSource.Mode.EXCLUDE, names 
= "DATA")
   void dvDeleteFileAdapterRejectsNonDataContent(FileContent contentType) {
-    TrackedFileStruct file = dummyTrackedFile(contentType);
+    TrackedFileStruct file = trackedFile(contentType);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asDVDeleteFile(file, 
UNPARTITIONED))
         .isInstanceOf(IllegalArgumentException.class)
@@ -389,7 +446,7 @@ class TestTrackedFileAdapters {
 
   @Test
   void dvDeleteFileAdapterRejectsNullDeletionVector() {
-    TrackedFileStruct file = dummyTrackedFile(FileContent.DATA);
+    TrackedFileStruct file = trackedFile(FileContent.DATA);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asDVDeleteFile(file, 
UNPARTITIONED))
         .isInstanceOf(IllegalArgumentException.class)
@@ -434,6 +491,10 @@ class TestTrackedFileAdapters {
     
assertThat(manifest.existingRowsCount()).isEqualTo(MANIFEST_INFO.existingRowsCount());
     
assertThat(manifest.deletedFilesCount()).isEqualTo(MANIFEST_INFO.deletedFilesCount());
     
assertThat(manifest.deletedRowsCount()).isEqualTo(MANIFEST_INFO.deletedRowsCount());
+    
assertThat(manifest.replacedFilesCount()).isEqualTo(MANIFEST_INFO.replacedFilesCount());
+    
assertThat(manifest.replacedRowsCount()).isEqualTo(MANIFEST_INFO.replacedRowsCount());
+    
assertThat(manifest.modifiedFilesCount()).isEqualTo(MANIFEST_INFO.modifiedFilesCount());
+    
assertThat(manifest.modifiedRowsCount()).isEqualTo(MANIFEST_INFO.modifiedRowsCount());
     assertThat(manifest.firstRowId()).isEqualTo(FIRST_ROW_ID);
     assertThat(manifest.keyMetadata()).isEqualTo(MANIFEST_KEY_METADATA);
     assertThat(manifest.manifestDeletionVector().buffer())
@@ -466,7 +527,7 @@ class TestTrackedFileAdapters {
       mode = EnumSource.Mode.EXCLUDE,
       names = {"DATA_MANIFEST", "DELETE_MANIFEST"})
   void manifestFileAdapterRejectsNonManifestContent(FileContent contentType) {
-    TrackedFileStruct file = dummyTrackedFile(contentType);
+    TrackedFileStruct file = trackedFile(contentType);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asManifestFile(file))
         .isInstanceOf(IllegalArgumentException.class)
@@ -485,7 +546,7 @@ class TestTrackedFileAdapters {
 
   @Test
   void nullContentStatsReturnsNullStats() {
-    TrackedFileStruct file = dummyTrackedFile(FileContent.DATA);
+    TrackedFileStruct file = trackedFile(FileContent.DATA);
 
     DataFile dataFile = TrackedFileAdapters.asDataFile(file, UNPARTITIONED);
 
@@ -501,7 +562,7 @@ class TestTrackedFileAdapters {
     // Files read before manifest inheritance have no tracking; 
tracking-derived fields must be
     // null rather than throwing.
     assertNullTrackingFields(
-        TrackedFileAdapters.asDataFile(dummyTrackedFile(FileContent.DATA), 
UNPARTITIONED));
+        TrackedFileAdapters.asDataFile(trackedFile(FileContent.DATA), 
UNPARTITIONED));
 
     TrackedFileStruct fileWithDV =
         new TrackedFileStruct(
@@ -524,7 +585,7 @@ class TestTrackedFileAdapters {
 
   @Test
   void unpartitionedFilePartitionIsEmpty() {
-    TrackedFileStruct file = dummyTrackedFile(FileContent.DATA);
+    TrackedFileStruct file = trackedFile(FileContent.DATA);
 
     DataFile dataFile = TrackedFileAdapters.asDataFile(file, UNPARTITIONED);
 
@@ -535,7 +596,7 @@ class TestTrackedFileAdapters {
   @Test
   void nullSpecIdResolvesToUnpartitionedSpec() {
     PartitionSpec unpartitioned = PartitionSpec.builderFor(new 
Schema()).withSpecId(5).build();
-    TrackedFileStruct file = dummyTrackedFile(FileContent.DATA);
+    TrackedFileStruct file = trackedFile(FileContent.DATA);
 
     DataFile dataFile = TrackedFileAdapters.asDataFile(file, 
specsById(unpartitioned));
 
@@ -546,7 +607,7 @@ class TestTrackedFileAdapters {
   void nullSpecIdThrowsWhenNoUnpartitionedSpec() {
     Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
     PartitionSpec partitioned = 
PartitionSpec.builderFor(schema).identity("id").build();
-    TrackedFileStruct file = dummyTrackedFile(FileContent.DATA);
+    TrackedFileStruct file = trackedFile(FileContent.DATA);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asDataFile(file, 
specsById(partitioned)))
         .isInstanceOf(IllegalArgumentException.class)
@@ -577,6 +638,267 @@ class TestTrackedFileAdapters {
         .hasMessageContaining("Cannot find partition spec for spec ID");
   }
 
+  @Test
+  void dataTrackedFileAdapterFromDataFile() {
+    TrackedFile result = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(DATA_FILE);
+
+    assertThat(result.tracking()).isNull();
+    assertWrappedDataFileMatchesFileFields(result, DATA_FILE);
+  }
+
+  @Test
+  void dataTrackedFileAdapterFromExistingManifestEntry() {
+    TrackedFile result =
+        TrackedFileAdapters.forDataFile(TABLE_SCHEMA)
+            .wrap(
+                new GenericManifestEntry<DataFile>(
+                        
ManifestEntry.getSchema(UNPARTITIONED_SPEC.partitionType()).asStruct())
+                    .wrapExisting(
+                        SNAPSHOT_ID, DATA_SEQUENCE_NUMBER, 
FILE_SEQUENCE_NUMBER, DATA_FILE));
+
+    assertThat(result.tracking().status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(result.tracking().snapshotId()).isEqualTo(SNAPSHOT_ID);
+    
assertThat(result.tracking().dataSequenceNumber()).isEqualTo(DATA_SEQUENCE_NUMBER);
+    
assertThat(result.tracking().fileSequenceNumber()).isEqualTo(FILE_SEQUENCE_NUMBER);
+    assertThat(result.tracking().firstRowId()).isEqualTo(FIRST_ROW_ID);
+    assertManifestPosition(result.tracking(), DATA_FILE);
+    assertWrappedDataFileMatchesFileFields(result, DATA_FILE);
+  }
+
+  @Test
+  void dataTrackedFileAdapterReuse() {
+    TrackedFileAdapters.DataTrackedFile adapter = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA);
+
+    adapter.wrap(DATA_FILE);
+    assertWrappedDataFileMatchesFileFields(adapter, DATA_FILE);
+    assertThat(adapter.tracking()).isNull();
+
+    DataFile file2 =
+        new GenericDataFile(
+            UNPARTITIONED_SPEC.specId(),
+            "s3://bucket/data/file2.parquet",
+            FileFormat.PARQUET,
+            PartitionData.EMPTY,
+            2048L,
+            new Metrics(200L),
+            null,
+            ImmutableList.of(0L),
+            null,
+            null);
+    assignManifestPosition(file2, "s3://bucket/table/manifest-2.parquet", 8L);
+    adapter.wrap(
+        new GenericManifestEntry<DataFile>(
+                
ManifestEntry.getSchema(UNPARTITIONED_SPEC.partitionType()).asStruct())
+            .wrapExisting(SNAPSHOT_ID, DATA_SEQUENCE_NUMBER, 
FILE_SEQUENCE_NUMBER, file2));
+    assertWrappedDataFileMatchesFileFields(adapter, file2);
+    assertThat(adapter.tracking().status()).isEqualTo(EntryStatus.EXISTING);
+    assertManifestPosition(adapter.tracking(), file2);
+  }
+
+  @Test
+  void dataTrackedFileAdapterRejectsNullFile() {
+    TrackedFileAdapters.DataTrackedFile adapter = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA);
+    assertThatThrownBy(() -> adapter.wrap((DataFile) null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Invalid file: null");
+  }
+
+  @Test
+  void dataTrackedFileAdapterUnwrapsToOriginalTrackedFile() {
+    TrackedFile original = trackedFile(FileContent.DATA);
+    DataFile adapted = TrackedFileAdapters.asDataFile(original, UNPARTITIONED);
+    TrackedFile roundTripped = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(adapted);
+    assertThat(roundTripped).isSameAs(original);
+  }
+
+  @Test
+  void dataTrackedFileAdapterContentStats() {
+    TrackedFile result = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(DATA_FILE_WITH_METRICS);
+
+    ContentStats stats = result.contentStats();
+    assertThat(stats).isNotNull();
+    
assertThat(stats.fieldStats()).extracting(FieldStats::fieldId).containsExactlyInAnyOrder(1,
 2);
+
+    FieldStats<?> idStats = stats.statsFor(1);
+    assertThat(idStats.valueCount()).isEqualTo(100L);
+    assertThat(idStats.lowerBound()).isEqualTo(1);
+    assertThat(idStats.upperBound()).isEqualTo(1000);
+  }
+
+  @Test
+  void dataTrackedFileAdapterWithoutMetricsHasNoContentStats() {
+    TrackedFile result = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(DATA_FILE);
+
+    assertThat(result.contentStats()).isNull();
+  }
+
+  @Test
+  void dataTrackedFileAdapterKeepsPartitionTuple() {
+    DataFile partitioned =
+        new GenericDataFile(
+            PARTITIONED_SPEC.specId(),
+            DATA_FILE_LOCATION,
+            FileFormat.PARQUET,
+            PARTITION,
+            1024L,
+            new Metrics(100L),
+            null,
+            ImmutableList.of(0L),
+            null,
+            null);
+    TrackedFile result = 
TrackedFileAdapters.forDataFile(TABLE_SCHEMA).wrap(partitioned);
+
+    assertThat(result.partition())
+        .usingComparator(Comparators.forType(PARTITIONED_SPEC.partitionType()))
+        .isEqualTo(PARTITION);
+  }
+
+  @Test
+  void manifestTrackedFileAdapterUnwrapsToOriginalTrackedFile() {
+    TrackedFile original = trackedFile(FileContent.DATA_MANIFEST);
+    ManifestFile adapted = TrackedFileAdapters.asManifestFile(original);
+    TrackedFile roundTripped = 
TrackedFileAdapters.forManifestFile().wrap(adapted);
+    assertThat(roundTripped).isSameAs(original);
+  }
+
+  @ParameterizedTest
+  @EnumSource(ManifestContent.class)
+  void manifestTrackedFileAdapter(ManifestContent content) {
+    FileContent expectedContent =
+        content == ManifestContent.DATA ? FileContent.DATA_MANIFEST : 
FileContent.DELETE_MANIFEST;
+    ManifestFile manifest =
+        new GenericManifestFile(
+            MANIFEST_LOCATION,
+            MANIFEST_FILE_SIZE,
+            UNPARTITIONED_SPEC.specId(),
+            content,
+            MANIFEST_SEQUENCE_NUMBER,
+            MANIFEST_MIN_SEQUENCE_NUMBER,
+            SNAPSHOT_ID,
+            ImmutableList.of(), // partitions
+            null, // key metadata
+            ADDED_FILES_COUNT,
+            ADDED_ROWS_COUNT,
+            EXISTING_FILES_COUNT,
+            EXISTING_ROWS_COUNT,
+            DELETED_FILES_COUNT,
+            DELETED_ROWS_COUNT,
+            content == ManifestContent.DATA ? FIRST_ROW_ID : null); // first 
row id
+    TrackedFile result = TrackedFileAdapters.forManifestFile().wrap(manifest);
+
+    assertThat(result.contentType()).isEqualTo(expectedContent);
+    assertThat(result.manifestInfo().formatVersion()).isZero();
+    assertThat(result.location()).isEqualTo(MANIFEST_LOCATION);
+    assertThat(result.fileFormat()).isEqualTo(FileFormat.AVRO);
+    assertThat(result.tracking().status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(result.tracking().snapshotId()).isEqualTo(SNAPSHOT_ID);
+    assertThat(result.tracking().firstRowId())
+        .isEqualTo(content == ManifestContent.DATA ? FIRST_ROW_ID : null);
+    assertThat(result.recordCount())
+        .isEqualTo(ADDED_FILES_COUNT + EXISTING_FILES_COUNT + 
DELETED_FILES_COUNT);
+    assertThat(result.manifestInfo()).isNotNull();
+    
assertThat(result.manifestInfo().addedFilesCount()).isEqualTo(ADDED_FILES_COUNT);
+    
assertThat(result.manifestInfo().existingFilesCount()).isEqualTo(EXISTING_FILES_COUNT);
+    
assertThat(result.manifestInfo().deletedFilesCount()).isEqualTo(DELETED_FILES_COUNT);
+    
assertThat(result.manifestInfo().addedRowsCount()).isEqualTo(ADDED_ROWS_COUNT);
+    
assertThat(result.manifestInfo().existingRowsCount()).isEqualTo(EXISTING_ROWS_COUNT);
+    
assertThat(result.manifestInfo().deletedRowsCount()).isEqualTo(DELETED_ROWS_COUNT);
+    assertThat(result.manifestInfo().replacedFilesCount()).isEqualTo(0);
+    assertThat(result.manifestInfo().replacedRowsCount()).isEqualTo(0L);
+    assertThat(result.manifestInfo().modifiedFilesCount()).isEqualTo(0);
+    assertThat(result.manifestInfo().modifiedRowsCount()).isEqualTo(0L);
+  }
+
+  @ParameterizedTest
+  @EnumSource(InvalidCount.class)
+  void manifestTrackedFileAdapterRejectsInvalidFilesCount(InvalidCount count) {
+    ManifestFile manifest = newManifestFileWithInvalidCount(count);
+    assertThatThrownBy(() -> 
TrackedFileAdapters.forManifestFile().wrap(manifest))
+        .isInstanceOf(count.exceptionType())
+        .hasMessage("Cannot convert manifest %s: %s", MANIFEST_LOCATION, 
count.message());
+  }
+
+  private static void assertWrappedDataFileMatchesFileFields(TrackedFile 
result, DataFile file) {
+    assertThat(result.contentType()).isEqualTo(FileContent.DATA);
+    assertThat(result.location()).isEqualTo(file.location());
+    assertThat(result.fileFormat()).isEqualTo(file.format());
+    assertThat(result.recordCount()).isEqualTo(file.recordCount());
+    assertThat(result.fileSizeInBytes()).isEqualTo(file.fileSizeInBytes());
+    assertThat(result.specId()).isEqualTo(file.specId());
+    assertThat(result.sortOrderId()).isEqualTo(file.sortOrderId());
+    assertThat(result.keyMetadata()).isEqualTo(file.keyMetadata());
+    assertThat(result.splitOffsets()).isEqualTo(file.splitOffsets());
+    assertThat(result.manifestInfo()).isNull();
+    assertThat(result.deletionVector()).isNull();
+  }
+
+  private static ManifestFile newManifestFileWithInvalidCount(InvalidCount 
count) {
+    ManifestFile manifest = mock(ManifestFile.class);
+    when(manifest.path()).thenReturn(MANIFEST_LOCATION);
+    when(manifest.content()).thenReturn(ManifestContent.DATA);
+    when(manifest.addedFilesCount()).thenReturn(ADDED_FILES_COUNT);
+    when(manifest.existingFilesCount()).thenReturn(EXISTING_FILES_COUNT);
+    when(manifest.deletedFilesCount()).thenReturn(DELETED_FILES_COUNT);
+    when(manifest.addedRowsCount()).thenReturn(ADDED_ROWS_COUNT);
+    when(manifest.existingRowsCount()).thenReturn(EXISTING_ROWS_COUNT);
+    when(manifest.deletedRowsCount()).thenReturn(DELETED_ROWS_COUNT);
+    when(manifest.replacedFilesCount()).thenReturn(0);
+    when(manifest.modifiedFilesCount()).thenReturn(0);
+    when(manifest.replacedRowsCount()).thenReturn(0L);
+    when(manifest.modifiedRowsCount()).thenReturn(0L);
+    switch (count) {
+      case ADDED_FILES -> when(manifest.addedFilesCount()).thenReturn(null);
+      case EXISTING_FILES -> 
when(manifest.existingFilesCount()).thenReturn(null);
+      case DELETED_FILES -> 
when(manifest.deletedFilesCount()).thenReturn(null);
+      case REPLACED_FILES_NULL -> 
when(manifest.replacedFilesCount()).thenReturn(null);
+      case MODIFIED_FILES_NULL -> 
when(manifest.modifiedFilesCount()).thenReturn(null);
+      case REPLACED_FILES_NON_ZERO ->
+          when(manifest.replacedFilesCount()).thenReturn(NON_ZERO_FILE_COUNT);
+      case MODIFIED_FILES_NON_ZERO ->
+          when(manifest.modifiedFilesCount()).thenReturn(NON_ZERO_FILE_COUNT);
+    }
+    return manifest;
+  }
+
+  private enum InvalidCount {
+    ADDED_FILES(NullPointerException.class, "missing added files count"),
+    EXISTING_FILES(NullPointerException.class, "missing existing files count"),
+    DELETED_FILES(NullPointerException.class, "missing deleted files count"),
+    REPLACED_FILES_NULL(IllegalArgumentException.class, "Invalid replaced file 
count: null"),
+    REPLACED_FILES_NON_ZERO(
+        IllegalArgumentException.class, "Invalid replaced file count: " + 
NON_ZERO_FILE_COUNT),
+    MODIFIED_FILES_NULL(IllegalArgumentException.class, "Invalid modified file 
count: null"),
+    MODIFIED_FILES_NON_ZERO(
+        IllegalArgumentException.class, "Invalid modified file count: " + 
NON_ZERO_FILE_COUNT);
+
+    private final Class<? extends RuntimeException> exceptionType;
+    private final String message;
+
+    InvalidCount(Class<? extends RuntimeException> exceptionType, String 
message) {
+      this.exceptionType = exceptionType;
+      this.message = message;
+    }
+
+    private Class<? extends RuntimeException> exceptionType() {
+      return exceptionType;
+    }
+
+    private String message() {
+      return message;
+    }
+  }
+
+  private static void assignManifestPosition(DataFile file, String location, 
long manifestPos) {
+    GenericDataFile dataFile = (GenericDataFile) file;
+    dataFile.setManifestLocation(location);
+    dataFile.set(DATA_FILE_POS_ORDINAL, manifestPos);
+  }
+
+  private static void assertManifestPosition(Tracking tracking, DataFile file) 
{
+    assertThat(tracking.manifestLocation()).isEqualTo(file.manifestLocation());
+    assertThat(tracking.manifestPos()).isEqualTo(file.pos());
+  }
+
   private static void assertNullTrackingFields(ContentFile<?> file) {
     assertThat(file.pos()).isNull();
     assertThat(file.manifestLocation()).isNull();
@@ -596,8 +918,8 @@ class TestTrackedFileAdapters {
     return partition;
   }
 
-  /** Minimal file with no tracking, used by the rejection and null-tracking 
tests. */
-  private static TrackedFileStruct dummyTrackedFile(FileContent contentType) {
+  /** Minimal file for the rejection and null-tracking tests. */
+  private static TrackedFileStruct trackedFile(FileContent contentType) {
     return new TrackedFileStruct(
         null,
         contentType,

Reply via email to