From 7efe23515837c4586e003ebfd65714a4c9f0bf6e Mon Sep 17 00:00:00 2001 From: Markus Opolka Date: Tue, 9 Jun 2026 16:12:13 +0200 Subject: [PATCH] Rework ParseBytes to return uint64 instead of interface Also replaces byte types with functions --- README.md | 12 ++- convert/bytes.go | 141 +++++++++++++++++++++++++++++++ convert/bytes_common.go | 104 ----------------------- convert/bytes_common_test.go | 77 ----------------- convert/bytes_iec.go | 38 --------- convert/bytes_iec_test.go | 41 --------- convert/bytes_si.go | 39 --------- convert/bytes_si_test.go | 42 ---------- convert/bytes_test.go | 156 +++++++++++++++++++++++++++++++++++ perfdata/list.go | 2 +- 10 files changed, 309 insertions(+), 343 deletions(-) create mode 100644 convert/bytes.go delete mode 100644 convert/bytes_common.go delete mode 100644 convert/bytes_common_test.go delete mode 100644 convert/bytes_iec.go delete mode 100644 convert/bytes_iec_test.go delete mode 100644 convert/bytes_si.go delete mode 100644 convert/bytes_si_test.go create mode 100644 convert/bytes_test.go diff --git a/README.md b/README.md index 1279dc3..7792c2e 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,17 @@ fmt.Println(o.GetOutput()) ## Human-readable bytes -`convert.BytesIEC` and `convert.BytesSI` can be used to represent a byte value with human-readable string output. +`ParseBytes` is a helper that can be used to parse string containering IEC or SI bytes into the number of bytes. + +```go +b, err := ParseBytes("2MiB") +// uint64 2 * 1024 * 1024 + +b, err := ParseBytes("1MB") +// uint64 1000 * 1000 +``` + +`BytesIEC` and `BytesSI` can be used to format a byte value with human-readable string output. ```go b := convert.BytesIEC(999) diff --git a/convert/bytes.go b/convert/bytes.go new file mode 100644 index 0000000..c564e4c --- /dev/null +++ b/convert/bytes.go @@ -0,0 +1,141 @@ +package convert + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// ByteIECUnits lists known units we can convert to based on uint64. +var ByteIECUnits = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + +// ByteSIUnits lists known units we can convert to based on uint64. +var ByteSIUnits = []string{"B", "KB", "MB", "GB", "TB", "PB"} + +// IECBase is the exponential base for IEC units. +const IECBase = 1024 + +// SIBase is the exponential base for SI units. +const SIBase = 1000 + +// ParseBytes parses a strings and returns the number of bytes +func ParseBytes(value string) (uint64, error) { + value = strings.TrimSpace(value) + + // Split number and unit by first non-numeric rune + firstNonNumeric := func(c rune) bool { return !(c >= '0' && c <= '9' || c == '.') } //nolint: staticcheck + + i := strings.IndexFunc(value, firstNonNumeric) + + var unit string + + if i > 0 { + unit = strings.TrimSpace(value[i:]) + value = value[0:i] + } + + // Parse value to float64 + number, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("provided value could not be parsed as float64: %s", value) + } + + if number < 0 { + return 0, fmt.Errorf("byte value cannot be negative: %s", value) + } + + // Assume byte when no unit given + if unit == "" { + unit = "B" + } + + // check for known units in ByteIECUnits + for exponent, u := range ByteIECUnits { + if u == unit { + result := number * math.Pow(IECBase, float64(exponent)) + + if result > math.MaxUint64 { + return 0, fmt.Errorf("provided value could not be parsed as it overflows uint64: %s", value) + } + + return uint64(result), nil + } + } + + // check for known units in ByteSIUnits + for exponent, u := range ByteSIUnits { + if u == unit { + result := number * math.Pow(SIBase, float64(exponent)) + + if result > math.MaxUint64 { + return 0, fmt.Errorf("provided value could not be parsed as it overflows uint64: %s", value) + } + + return uint64(result), nil + } + } + + return 0, fmt.Errorf("invalid unit: %s", unit) +} + +// BytesSI returns the biggest sensible unit for the byte value with 2 decimal precision. +// When value is smaller than 2 render it with a lower scale. +func BytesSI(b uint64) string { + value, unit := humanReadable(b, ByteSIUnits, SIBase) + + // Remove trailing zero decimals and any left over decimal dot + s := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".") + + return s + unit +} + +// BytesIEC returns the biggest sensible unit for the byte value with 2 decimal precision. +// When value is smaller than 2 render it with a lower scale. +func BytesIEC(b uint64) string { + value, unit := humanReadable(b, ByteIECUnits, IECBase) + + // Remove trailing zero decimals and any left over decimal dot + s := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".") + + return s + unit +} + +// humanReadable searches for the closest feasible unit for displaying the byte value to a human. +// +// Meant as a universal function to be used by the implementations, with base and a list of unit names. +// +// A special behavior is that resulting values smaller than 2 are displayed with the lower exponent. +// If the input value is 0, humanReadable will always return "0B" +// +// Examples: +// +// 1073741824B -> 1000KB +// 2147483648B -> 2MB +// 0 -> 0B +func humanReadable(b uint64, units []string, base float64) (float64, string) { + if b == 0 { + return 0, "B" + } + + exponent := math.Log(float64(b)) / math.Log(base) + + // Round to the unit scaled exponent + unitExponent := math.Floor(exponent) + + // Ensure we only scale to the maximum known unit + maxScale := float64(len(units) - 1) + if unitExponent > maxScale { + unitExponent = maxScale + } + + value := math.Pow(base, exponent-unitExponent) + + // When resulting value is smaller than 2 calculate 1XXXM(i)B instead of 1.XXG(i)B + if unitExponent > 0 && math.Round(value*base)/base < 2.0 { + unitExponent-- + value = math.Pow(base, exponent-unitExponent) + } + + return value, units[int(unitExponent)] +} diff --git a/convert/bytes_common.go b/convert/bytes_common.go deleted file mode 100644 index 764417e..0000000 --- a/convert/bytes_common.go +++ /dev/null @@ -1,104 +0,0 @@ -package convert - -import ( - "fmt" - "math" - "strconv" - "strings" -) - -type ByteAny interface { - HumanReadable() string - Bytes() uint64 - fmt.Stringer -} - -// ExponentialFold defines the value to fold back to the previous exponent for better display of a small value. -const ExponentialFold = 2 - -// ParseBytes parses a strings and returns the proper ByteAny implementation based on the unit specified. -// -// When a plain value or B unit is used, ByteIEC is returned. -func ParseBytes(value string) (ByteAny, error) { - value = strings.TrimSpace(value) - - // Split number and unit by first non-numeric rune - firstNonNumeric := func(c rune) bool { return !(c >= '0' && c <= '9' || c == '.') } //nolint: staticcheck - - i := strings.IndexFunc(value, firstNonNumeric) - - var unit string - - if i > 0 { - unit = strings.TrimSpace(value[i:]) - value = value[0:i] - } - - // Parse value to float64 - number, err := strconv.ParseFloat(value, 64) // nolint:gomnd - if err != nil { - return nil, fmt.Errorf("provided value could not be parsed as float64: %s", value) - } - - // Assume byte when no unit given - if unit == "" { - unit = "B" - } - - // check for known units in ByteIECUnits - for exponent, u := range ByteIECUnits { - if u == unit { - // convert to bytes and return type - return BytesIEC(number * math.Pow(IECBase, float64(exponent))), nil - } - } - - // check for known units in ByteSIUnits - for exponent, u := range ByteSIUnits { - if u == unit { - // convert to bytes and return type - return BytesSI(number * math.Pow(SIBase, float64(exponent))), nil - } - } - - return nil, fmt.Errorf("invalid unit: %s", unit) -} - -// humanReadable searches for the closest feasible unit for displaying the byte value to a human. -// -// Meant as a universal function to be used by the implementations, with base and a list of unit names. -// -// A special behavior is that resulting values smaller than 2 are displayed with the lower exponent. -// If the input value is 0, humanReadable will always return "0B" -// -// Examples: -// -// 1073741824B -> 1000KB -// 2147483648B -> 2MB -// 0 -> 0MB -func humanReadable(b uint64, units []string, base float64) (float64, string) { - if b == 0 { - return 0, "B" - } - - exponent := math.Log(float64(b)) / math.Log(base) - - // Round to the unit scaled exponent - unitExponent := math.Floor(exponent) - - // Ensure we only scale to the maximum known unit - maxScale := float64(len(units) - 1) - if unitExponent > maxScale { - unitExponent = maxScale - } - - value := math.Pow(base, exponent-unitExponent) - - // When resulting value is smaller than 2 calculate 1XXXM(i)B instead of 1.XXG(i)B - if math.Round(value*base)/base < ExponentialFold { - unitExponent-- - value = math.Pow(base, exponent-unitExponent) - } - - return value, units[int(unitExponent)] -} diff --git a/convert/bytes_common_test.go b/convert/bytes_common_test.go deleted file mode 100644 index 66f346a..0000000 --- a/convert/bytes_common_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package convert - -import ( - "testing" -) - -func TestParseBytes(t *testing.T) { - b, err := ParseBytes("1024") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if _, ok := b.(BytesIEC); !ok { - t.Fatalf("expected type BytesIEC, got %T", b) - } - if b.Bytes() != 1024 { - t.Fatalf("expected 1024 bytes, got %d", b.Bytes()) - } - - b, err = ParseBytes("1MB") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if _, ok := b.(BytesSI); !ok { - t.Fatalf("expected type BytesSI, got %T", b) - } - if b.Bytes() != 1000*1000 { - t.Fatalf("expected 1000000 bytes, got %d", b.Bytes()) - } - - b, err = ParseBytes("1 MiB") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if _, ok := b.(BytesIEC); !ok { - t.Fatalf("expected type BytesIEC, got %T", b) - } - if b.Bytes() != 1024*1024 { - t.Fatalf("expected 1048576 bytes, got %d", b.Bytes()) - } - - b, err = ParseBytes("100MB") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - b, err = ParseBytes("100MiB") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - b, err = ParseBytes(" 23 GiB ") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if _, ok := b.(BytesIEC); !ok { - t.Fatalf("expected type BytesIEC, got %T", b) - } - if b.Bytes() != 23*1024*1024*1024 { - t.Fatalf("expected 24742653952 bytes, got %d", b.Bytes()) - } - - b, err = ParseBytes("1.2.3.4MB") - if err == nil { - t.Fatalf("expected error, got nil") - } - if b != nil { - t.Fatalf("expected nil, got %v", b) - } - - b, err = ParseBytes("1PHD") - if err == nil { - t.Fatalf("expected error, got nil") - } - if b != nil { - t.Fatalf("expected nil, got %v", b) - } -} diff --git a/convert/bytes_iec.go b/convert/bytes_iec.go deleted file mode 100644 index 5f69fcf..0000000 --- a/convert/bytes_iec.go +++ /dev/null @@ -1,38 +0,0 @@ -package convert - -import ( - "strconv" - "strings" -) - -// ByteIECUnits lists known units we can convert to based on uint64. -// -// See https://en.wikipedia.org/wiki/Byte#Unit_symbol -var ByteIECUnits = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} - -// BytesIEC is the IEC (1024) unit implementation of byte conversion. -type BytesIEC uint64 - -// IECBase is the exponential base for IEC units. -const IECBase = 1024 - -// HumanReadable returns the biggest sensible unit for the byte value with 2 decimal precision. -// -// When value is smaller than 2 render it with a lower scale. -func (b BytesIEC) HumanReadable() string { - value, unit := humanReadable(uint64(b), ByteIECUnits, IECBase) - - // Remove trailing zero decimals and any left over decimal dot - s := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".") - - return s + unit -} - -func (b BytesIEC) String() string { - return b.HumanReadable() -} - -// Bytes returns the value as uint64. -func (b BytesIEC) Bytes() uint64 { - return uint64(b) -} diff --git a/convert/bytes_iec_test.go b/convert/bytes_iec_test.go deleted file mode 100644 index c2ac417..0000000 --- a/convert/bytes_iec_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package convert - -import ( - "testing" -) - -type bytesIECTestCase struct { - name string - input BytesIEC - expected string -} - -func TestBytesIEC_HumanReadable(t *testing.T) { - tests := []bytesIECTestCase{ - {name: "zero", input: BytesIEC(0), expected: "0B"}, - {name: "less than 1K", input: BytesIEC(999), expected: "999B"}, - {name: "999 KiB", input: BytesIEC(999 * 1024), expected: "999KiB"}, - {name: "999 MiB", input: BytesIEC(999 * 1024 * 1024), expected: "999MiB"}, - {name: "999 GiB", input: BytesIEC(999 * 1024 * 1024 * 1024), expected: "999GiB"}, - {name: "999 TiB", input: BytesIEC(999 * 1024 * 1024 * 1024 * 1024), expected: "999TiB"}, - {name: "4 PiB", input: BytesIEC(4 * 1024 * 1024 * 1024 * 1024 * 1024), expected: "4PiB"}, - {name: "4096 PiB", input: BytesIEC(4 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024), expected: "4096PiB"}, - {name: "1263 MiB", input: BytesIEC(1263 * 1024 * 1024), expected: "1263MiB"}, - {name: "100 MiB", input: BytesIEC(100 * 1024 * 1024), expected: "100MiB"}, - {name: "123.05 MiB", input: BytesIEC(129032519), expected: "123.05MiB"}, - {name: "14.67 GiB", input: BytesIEC(15756365824), expected: "14.67GiB"}, - {name: "1024 KiB", input: BytesIEC(1024 * 1024), expected: "1024KiB"}, - {name: "2 MiB", input: BytesIEC(2 * 1024 * 1024), expected: "2MiB"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.input.HumanReadable(); got != tt.expected { - t.Fatalf("expected %q, got %q", tt.expected, got) - } - if got := tt.input.String(); got != tt.expected { - t.Fatalf("expected %q, got %q", tt.expected, got) - } - }) - } -} diff --git a/convert/bytes_si.go b/convert/bytes_si.go deleted file mode 100644 index 26f329f..0000000 --- a/convert/bytes_si.go +++ /dev/null @@ -1,39 +0,0 @@ -package convert - -import ( - "strconv" - "strings" -) - -// ByteSIUnits lists known units we can convert to based on uint64. -// -// See https://en.wikipedia.org/wiki/Byte#Unit_symbol -var ByteSIUnits = []string{"B", "KB", "MB", "GB", "TB", "PB"} - -// BytesSI is the SI (1000) unit implementation of byte conversion. -type BytesSI uint64 - -// SIBase is the exponential base for SI units. -const SIBase = 1000 - -// HumanReadable returns the biggest sensible unit for the byte value with 2 decimal precision. -// -// When value is smaller than 2 render it with a lower scale. -func (b BytesSI) HumanReadable() string { - value, unit := humanReadable(uint64(b), ByteSIUnits, SIBase) - - // Remove trailing zero decimals and any left over decimal dot - s := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".") - - return s + unit -} - -// String returns a text representation of the current value. -func (b BytesSI) String() string { - return b.HumanReadable() -} - -// Bytes returns the value as uint64. -func (b BytesSI) Bytes() uint64 { - return uint64(b) -} diff --git a/convert/bytes_si_test.go b/convert/bytes_si_test.go deleted file mode 100644 index 38fc1c2..0000000 --- a/convert/bytes_si_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package convert - -import ( - "testing" -) - -type bytesSITestCase struct { - input BytesSI - expected string -} - -func TestBytesSI_HumanReadable2(t *testing.T) { - tests := []bytesSITestCase{ - {BytesSI(0), "0B"}, - {BytesSI(999), "999B"}, - {BytesSI(999 * 1000), "999KB"}, - {BytesSI(999 * 1000 * 1000), "999MB"}, - {BytesSI(999 * 1000 * 1000 * 1000), "999GB"}, - {BytesSI(999 * 1000 * 1000 * 1000 * 1000), "999TB"}, - {BytesSI(4 * 1000 * 1000 * 1000 * 1000 * 1000), "4PB"}, - {BytesSI(4 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000), "4000PB"}, - {BytesSI(4 * 1000 * 1000 * 1000 * 1000), "4TB"}, - {BytesSI(4 * 1000 * 1000 * 1000 * 1000 * 1000), "4PB"}, - {BytesSI(1263 * 1000 * 1000), "1263MB"}, - {BytesSI(123050 * 1000), "123.05MB"}, - {BytesSI(14670 * 1000 * 1000), "14.67GB"}, - {BytesSI(1000 * 1000), "1000KB"}, - {BytesSI(2 * 1000 * 1000), "2MB"}, - {BytesSI(3 * 1000 * 1000), "3MB"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - if got := tt.input.HumanReadable(); got != tt.expected { - t.Fatalf("expected %q, got %q", tt.expected, got) - } - if got := tt.input.String(); got != tt.expected { - t.Fatalf("expected %q, got %q", tt.expected, got) - } - }) - } -} diff --git a/convert/bytes_test.go b/convert/bytes_test.go new file mode 100644 index 0000000..8bc18d5 --- /dev/null +++ b/convert/bytes_test.go @@ -0,0 +1,156 @@ +package convert + +import ( + "fmt" + "testing" +) + +func TestParseBytes(t *testing.T) { + tests := []struct { + name string + input string + expected uint64 + }{ + {name: "zero", expected: 0, input: "0B"}, + {name: "0", expected: 0, input: "0"}, + {name: "1024", expected: 1024, input: "1024"}, + {name: "2 MiB", expected: 2 * 1024 * 1024, input: "2MiB"}, + {name: "1MB", expected: 1000 * 1000, input: "1MB"}, + {name: "100MB", expected: 1000 * 1000 * 100, input: "100MB"}, + {name: " 23 GiB", expected: 23 * 1024 * 1024 * 1024, input: " 23 GiB "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := ParseBytes(tt.input) + + if err != nil { + t.Fatalf("did not expect error, got %v", err) + } + + if actual != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, actual) + } + }) + } +} + +func TestParseBytes_WithError(t *testing.T) { + tests := []struct { + input string + }{ + { + input: "unittest", + }, + { + input: "1PHD", + }, + { + input: "", + }, + { + input: "1.2.3.4MB", + }, + { + input: "-1", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + _, err := ParseBytes(tt.input) + + if err == nil { + t.Fatalf("did expect error, got nil") + } + }) + } +} + +func ExampleParseBytes() { + b, err := ParseBytes("999KiB") + + if err != nil { + panic("Could not parse string into number of bytes") + } + + fmt.Println(b) + // Output: 1022976 +} + +func TestBytesIEC_HumanReadable(t *testing.T) { + tests := []struct { + name string + input uint64 + expected string + }{ + {name: "zero", input: 0, expected: "0B"}, + {name: "less than 1K", input: 999, expected: "999B"}, + {name: "999 KiB", input: 999 * 1024, expected: "999KiB"}, + {name: "999 MiB", input: 999 * 1024 * 1024, expected: "999MiB"}, + {name: "999 GiB", input: 999 * 1024 * 1024 * 1024, expected: "999GiB"}, + {name: "999 TiB", input: 999 * 1024 * 1024 * 1024 * 1024, expected: "999TiB"}, + {name: "4 PiB", input: 4 * 1024 * 1024 * 1024 * 1024 * 1024, expected: "4PiB"}, + {name: "4096 PiB", input: 4 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024, expected: "4096PiB"}, + {name: "1263 MiB", input: 1263 * 1024 * 1024, expected: "1263MiB"}, + {name: "100 MiB", input: 100 * 1024 * 1024, expected: "100MiB"}, + {name: "123.05 MiB", input: 129032519, expected: "123.05MiB"}, + {name: "14.67 GiB", input: 15756365824, expected: "14.67GiB"}, + {name: "1024 KiB", input: 1024 * 1024, expected: "1024KiB"}, + {name: "2 MiB", input: 2 * 1024 * 1024, expected: "2MiB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BytesIEC(tt.input); got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func ExampleBytesIEC() { + b := BytesIEC(999 * 1024) + + fmt.Println(b) + // Output: 999KiB +} + +func TestBytesSI_HumanReadable(t *testing.T) { + tests := []struct { + input uint64 + expected string + }{ + {input: 0, expected: "0B"}, + {input: 999, expected: "999B"}, + {input: 999 * 1000, expected: "999KB"}, + {input: 999 * 1000 * 1000, expected: "999MB"}, + {input: 999 * 1000 * 1000 * 1000, expected: "999GB"}, + {input: 999 * 1000 * 1000 * 1000 * 1000, expected: "999TB"}, + {input: 4 * 1000 * 1000 * 1000 * 1000 * 1000, expected: "4PB"}, + {input: 4 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000, expected: "4000PB"}, + {input: 4 * 1000 * 1000 * 1000 * 1000, expected: "4TB"}, + {input: 4 * 1000 * 1000 * 1000 * 1000 * 1000, expected: "4PB"}, + {input: 1263 * 1000 * 1000, expected: "1263MB"}, + {input: 123050 * 1000, expected: "123.05MB"}, + {input: 14670 * 1000 * 1000, expected: "14.67GB"}, + {input: 1000 * 1000, expected: "1000KB"}, + {input: 2 * 1000 * 1000, expected: "2MB"}, + {input: 3 * 1000 * 1000, expected: "3MB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := BytesSI(tt.input); got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func ExampleBytesSI() { + b := BytesSI(999 * 1000) + + fmt.Println(b) + // Output: 999KB +} diff --git a/perfdata/list.go b/perfdata/list.go index 2130ff0..3d2bfb5 100644 --- a/perfdata/list.go +++ b/perfdata/list.go @@ -5,7 +5,7 @@ import ( ) // PerfdataList can store multiple perfdata and brings a simple fmt.Stringer interface -// nolint: golint, revive +// nolint: revive type PerfdataList []*Perfdata // String returns string representations of all Perfdata