DataSize.java

package io.jstach.rainbowgum.file;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;

import org.jspecify.annotations.Nullable;

/**
 * A non negative amount of data in bytes, such as a maximum file size.
 * <p>
 * {@link #parse(String)} accepts a number followed by an optional unit, with optional
 * whitespace around and between them. Every unit is a power of 1024 and units are case
 * insensitive:
 * <ul>
 * <li>none or {@code b}: bytes</li>
 * <li>{@code k}, {@code kb}, {@code kib}: 1024 bytes</li>
 * <li>{@code m}, {@code mb}, {@code mib}: 1024<sup>2</sup> bytes</li>
 * <li>{@code g}, {@code gb}, {@code gib}: 1024<sup>3</sup> bytes</li>
 * <li>{@code t}, {@code tb}, {@code tib}: 1024<sup>4</sup> bytes</li>
 * </ul>
 * The number may have a decimal part using {@code .} (never {@code ,}), for example
 * {@code 1.5GB}; a result that is not a whole number of bytes is rounded down. Every
 * value Logback's {@code FileSize} accepts, including its optional trailing {@code s} as
 * in {@code 10MBs}, parses to the same number of bytes. Unlike some other formats,
 * {@code MB} never means 1000<sup>2</sup>.
 */
public final class DataSize implements Comparable<DataSize> {

	private static final Pattern PATTERN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)\\s*([a-zA-Z]*)");

	private static final String FORMAT_HINT = "Expected a number with an optional unit of b, k/kb/kib, m/mb/mib, g/gb/gib, or t/tb/tib (case insensitive, powers of 1024). Examples: '10MB', '512k', '1.5 GiB', '1048576'.";

	private static final long KB = 1024L;

	private static final long MB = KB * 1024L;

	private static final long GB = MB * 1024L;

	private static final long TB = GB * 1024L;

	/**
	 * Zero bytes.
	 */
	public static final DataSize ZERO = new DataSize(0);

	private final long bytes;

	private DataSize(long bytes) {
		this.bytes = bytes;
	}

	/**
	 * Creates a data size from a number of bytes.
	 * @param bytes number of bytes, must not be negative.
	 * @return data size.
	 * @throws IllegalArgumentException if bytes is negative.
	 */
	public static DataSize ofBytes(long bytes) {
		if (bytes < 0) {
			throw new IllegalArgumentException("Data size must not be negative. bytes: " + bytes);
		}
		return bytes == 0 ? ZERO : new DataSize(bytes);
	}

	/**
	 * Creates a data size from a number of kilobytes (1024 bytes each).
	 * @param kilobytes number of kilobytes, must not be negative.
	 * @return data size.
	 * @throws IllegalArgumentException if negative or too large.
	 */
	public static DataSize ofKilobytes(long kilobytes) {
		return ofBytes(multiply(kilobytes, KB, kilobytes + "kb"));
	}

	/**
	 * Creates a data size from a number of megabytes (1024 * 1024 bytes each).
	 * @param megabytes number of megabytes, must not be negative.
	 * @return data size.
	 * @throws IllegalArgumentException if negative or too large.
	 */
	public static DataSize ofMegabytes(long megabytes) {
		return ofBytes(multiply(megabytes, MB, megabytes + "mb"));
	}

	/**
	 * Creates a data size from a number of gigabytes (1024 * 1024 * 1024 bytes each).
	 * @param gigabytes number of gigabytes, must not be negative.
	 * @return data size.
	 * @throws IllegalArgumentException if negative or too large.
	 */
	public static DataSize ofGigabytes(long gigabytes) {
		return ofBytes(multiply(gigabytes, GB, gigabytes + "gb"));
	}

	/**
	 * Parses a data size such as {@code 10MB}, {@code 512 kb}, {@code 1.5g}, or
	 * {@code 1048576}. See the class documentation for the format.
	 * @param value text to parse.
	 * @return data size.
	 * @throws IllegalArgumentException if the text is not a valid data size.
	 */
	public static DataSize parse(String value) {
		var m = PATTERN.matcher(value.strip());
		if (!m.matches()) {
			throw new IllegalArgumentException("Invalid data size: '" + value + "'. " + FORMAT_HINT);
		}
		String number = Objects.requireNonNull(m.group(1));
		String unit = Objects.requireNonNullElse(m.group(2), "");
		long multiplier = switch (unit.toLowerCase(Locale.ROOT)) {
			case "", "b", "s" -> 1L;
			case "k", "kb", "kib", "kbs" -> KB;
			case "m", "mb", "mib", "mbs" -> MB;
			case "g", "gb", "gib", "gbs" -> GB;
			case "t", "tb", "tib", "tbs" -> TB;
			default -> throw new IllegalArgumentException(
					"Invalid data size: '" + value + "'. Unknown unit '" + unit + "'. " + FORMAT_HINT);
		};
		BigInteger bytes = new BigDecimal(number).multiply(BigDecimal.valueOf(multiplier)).toBigInteger();
		if (bytes.bitLength() >= Long.SIZE) {
			throw tooLarge(value);
		}
		return ofBytes(bytes.longValue());
	}

	private static long multiply(long number, long multiplier, String description) {
		if (number < 0) {
			throw new IllegalArgumentException("Data size must not be negative: '" + description + "'");
		}
		try {
			return Math.multiplyExact(number, multiplier);
		}
		catch (ArithmeticException e) {
			throw tooLarge(description);
		}
	}

	private static IllegalArgumentException tooLarge(String value) {
		return new IllegalArgumentException(
				"Data size is too large: '" + value + "'. Maximum is " + Long.MAX_VALUE + " bytes.");
	}

	/**
	 * The size in bytes.
	 * @return bytes, never negative.
	 */
	public long toBytes() {
		return bytes;
	}

	@Override
	public int compareTo(DataSize o) {
		return Long.compare(bytes, o.bytes);
	}

	@Override
	public boolean equals(@Nullable Object obj) {
		return obj instanceof DataSize other && other.bytes == bytes;
	}

	@Override
	public int hashCode() {
		return Long.hashCode(bytes);
	}

	/**
	 * A string that {@link #parse(String)} accepts, using the largest unit that divides
	 * the size exactly, for example {@code 10MB} or {@code 1500}.
	 * @return parseable text.
	 */
	@Override
	public String toString() {
		if (bytes != 0) {
			if (bytes % TB == 0) {
				return (bytes / TB) + "TB";
			}
			if (bytes % GB == 0) {
				return (bytes / GB) + "GB";
			}
			if (bytes % MB == 0) {
				return (bytes / MB) + "MB";
			}
			if (bytes % KB == 0) {
				return (bytes / KB) + "KB";
			}
		}
		return Long.toString(bytes);
	}

}