diff --git a/Sources/ContainerPersistence/MemorySize.swift b/Sources/ContainerPersistence/MemorySize.swift index e0762b37d..d5fc645f6 100644 --- a/Sources/ContainerPersistence/MemorySize.swift +++ b/Sources/ContainerPersistence/MemorySize.swift @@ -49,6 +49,16 @@ public struct MemorySize: Codable, Sendable, Equatable, CustomStringConvertible ] public var formatted: String { + // Only whole-number sizes can be represented in the stored unit without + // loss. A fractional value (e.g. "1.5gb") would truncate to "1gb" and + // silently drop memory when re-encoded, so emit the exact byte count + // instead. Binary units are integer multiples of a byte, so this keeps + // the encode/decode round-trip lossless while leaving whole-number + // output (e.g. "1gb", "2048mb") unchanged. + guard measurement.value == measurement.value.rounded() else { + let bytes = UInt64(measurement.converted(to: .bytes).value.rounded()) + return "\(bytes)\(Self.unitLabels[.bytes] ?? "b")" + } let value = Int64(measurement.value) let label = Self.unitLabels[measurement.unit] ?? "unknown" return "\(value)\(label)" diff --git a/Tests/ContainerAPIClientTests/MemorySizeTests.swift b/Tests/ContainerAPIClientTests/MemorySizeTests.swift index 1d518db72..6baa2be16 100644 --- a/Tests/ContainerAPIClientTests/MemorySizeTests.swift +++ b/Tests/ContainerAPIClientTests/MemorySizeTests.swift @@ -61,6 +61,15 @@ struct MemorySizeTests { #expect(original == decoded) } + @Test(arguments: ["1.5gb", "0.5gb", "2.25tb", "1.5mb"]) + func testRoundTripEncodingFractional(input: String) throws { + let original = try MemorySize(input) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(MemorySize.self, from: data) + #expect(original == decoded) + #expect(original.toUInt64(unit: .bytes) == decoded.toUInt64(unit: .bytes)) + } + @Test func testDecodingFromString() throws { let json = Data("\"512kb\"".utf8) let decoded = try JSONDecoder().decode(MemorySize.self, from: json)