From a089c38b55ddce696371bd57c165ff04e5463b9b Mon Sep 17 00:00:00 2001
From: Janos Bonic <86970079+janosdebugs@users.noreply.github.com>
Date: Mon, 12 Sep 2022 22:02:04 +0200
Subject: [PATCH] Units implemented
---
.github/dependabot.yml | 6 +
.github/pull_request_template.md | 11 +
.github/scripts/gogenerate.sh | 27 ++
.github/workflows/build.yml | 65 +++++
.golangci.yml | 93 +++++++
go.mod | 3 +
schema/doc.go | 2 +
schema/errors.go | 80 ++++++
schema/types.go | 44 +++
schema/units.go | 457 +++++++++++++++++++++++++++++++
schema/units_test.go | 42 +++
11 files changed, 830 insertions(+)
create mode 100644 .github/dependabot.yml
create mode 100644 .github/pull_request_template.md
create mode 100755 .github/scripts/gogenerate.sh
create mode 100644 .github/workflows/build.yml
create mode 100644 .golangci.yml
create mode 100644 go.mod
create mode 100644 schema/doc.go
create mode 100644 schema/errors.go
create mode 100644 schema/types.go
create mode 100644 schema/units.go
create mode 100644 schema/units_test.go
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..20e1ef1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "gomod"
+ directory: "/"
+ schedule:
+ interval: "daily"
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..ebf171a
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## Please describe the change you are making
+
+...
+
+## Are you the owner of the code you are sending in, or do you have permission of the owner?
+
+...
+
+## The code will be published under the BSD 3 clause license. Have you read and understood this license?
+
+...
diff --git a/.github/scripts/gogenerate.sh b/.github/scripts/gogenerate.sh
new file mode 100755
index 0000000..3bd55c6
--- /dev/null
+++ b/.github/scripts/gogenerate.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+export NOLINT=1
+go generate >/tmp/gogenerate.output 2>/tmp/gogenerate.output
+if [ $? -ne 0 ]; then
+ echo -e "::group::\e[0;31m❌ Go generate failed.\e[0m"
+ cat /tmp/gogenerate.output
+ echo "::endgroup::"
+ exit 1
+fi
+
+echo -e "::group::\e[0;32m✅ Go generate succeeded.\e[0m"
+cat /tmp/gogenerate.output
+echo "::endgroup::"
+
+git diff >/tmp/gogenerate.diff
+if [ "$(cat /tmp/gogenerate.diff | wc -l)" -ne 0 ]; then
+ echo -e "::group::\e[0;31m❌ Git changes after go generate.\e[0m"
+ echo "The following is the diff of files:"
+ cat /tmp/gogenerate.diff
+ echo "::endgroup::"
+ echo -e "\e[0;31m⚙ Please run go generate before pushing your changes.\e[0m"
+ exit 1
+fi
+
+echo -e "::group::\e[0;32m✅ No changes after go generate.\e[0m"
+echo "::endgroup::"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..33d1ed2
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,65 @@
+name: Build
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+jobs:
+ golangci-lint:
+ name: golangci-lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ - name: Run golangci-lint
+ uses: golangci/golangci-lint-action@v2
+ test:
+ name: go test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.18
+ - name: Set up gotestfmt
+ uses: haveyoudebuggedit/gotestfmt-action@v2
+ - uses: actions/cache@v2
+ with:
+ path: |
+ ~/go/pkg/mod
+ ~/.cache/go-build
+ key: go-test-${{ hashFiles('**/go.sum') }}
+ restore-keys: go-test-
+ - name: Run go test
+ run: |
+ set -euo pipefail
+ go generate
+ go test -json -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt
+ - name: Upload test log
+ uses: actions/upload-artifact@v2
+ if: always()
+ with:
+ name: test-log
+ path: /tmp/gotest.log
+ if-no-files-found: error
+ generate:
+ name: go generate
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.16
+ - uses: actions/cache@v2
+ with:
+ path: |
+ ~/go/pkg/mod
+ ~/.cache/go-build
+ key: go-test-${{ hashFiles('**/go.sum') }}
+ restore-keys: go-generate-
+ - name: Run go generate
+ run: ./.github/scripts/gogenerate.sh
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..b093175
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,93 @@
+run:
+ timeout: 5m
+linters:
+ enable:
+ # region General
+
+ # Add depguard to prevent adding additional dependencies. This is a client library, we really don't want
+ # additional dependencies.
+ - depguard
+ # Prevent improper directives in go.mod.
+ - gomoddirectives
+ # Prevent improper nolint directives.
+ - nolintlint
+
+ # endregion
+
+
+ # region Code Quality and Comments
+
+ # Inspect source code for potential security problems. This check has a fairly high false positive rate,
+ # comment with // nolint:gosec where not relevant.
+ - gosec
+ # Replace golint.
+ - revive
+ # Complain about deeply nested if cases.
+ - nestif
+ # Prevent naked returns in long functions.
+ - nakedret
+ # Make Go code more readable.
+ - gocritic
+ # We don't want hidden global scope, so disallow global variables. You can disable this with
+ # Check if comments end in a period. This helps prevent incomplete comment lines, such as half-written sentences.
+ - godot
+ # Complain about comments as these indicate incomplete code.
+ - godox
+ # Keep the cyclomatic complexity of functions to a reasonable level.
+ - gocyclo
+ # Complain about cognitive complexity of functions.
+ - gocognit
+ # Find repeated strings that could be converted into constants.
+ - goconst
+ # Complain about unnecessary type conversions.
+ - unconvert
+ # Complain about unused parameters. These should be replaced with underscores.
+ - unparam
+ # Check for non-ASCII identifiers.
+ - asciicheck
+ # Check for HTTP response body being closed. Sometimes, you may need to disable this using // nolint:bodyclose.
+ - bodyclose
+ # Check for duplicate code. You may want to disable this with // nolint:dupl if the source code is the same, but
+ # legitimately exists for different reasons.
+ - dupl
+ # Check for pointers in loops. This is a typical bug source.
+ - exportloopref
+ # Enforce a reasonable function length of 60 lines or 40 instructions. In very rare cases you may want to disable
+ # this with // nolint:funlen if there is absolutely no way to split the function in question.
+ - funlen
+ # Prevent dogsledding (mass-ignoring return values). This typically indicates missing error handling.
+ - dogsled
+ # Enforce consistent import aliases across all files.
+ - importas
+ # Make code properly formatted.
+ - gofmt
+ # Prevent faulty error checks.
+ - nilerr
+ # Prevent direct error checks that won't work with wrapped errors.
+ - errorlint
+ # Find slice usage that could potentially be preallocated.
+ - prealloc
+ # Check for improper duration handling.
+ - durationcheck
+ # Enforce tests being in the _test package.
+ - testpackage
+
+ # endregion
+linters-settings:
+ depguard:
+ list-type: whitelist
+ include-go-root: false
+ packages:
+ - go.flow.arcalot.io/pluginsdk/schema
+ govet:
+ enable-all: true
+ check-shadowing: false
+ disable:
+ # We don't care about variable shadowing.
+ - shadow
+ - fieldalignment
+ stylecheck:
+ checks:
+ - all
+issues:
+ exclude-use-default: false
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b382bb2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module go.flow.arcalot.io/pluginsdk
+
+go 1.18
diff --git a/schema/doc.go b/schema/doc.go
new file mode 100644
index 0000000..68681cd
--- /dev/null
+++ b/schema/doc.go
@@ -0,0 +1,2 @@
+// Package schema contains the Arcaflow schema system.
+package schema
diff --git a/schema/errors.go b/schema/errors.go
new file mode 100644
index 0000000..12d6eae
--- /dev/null
+++ b/schema/errors.go
@@ -0,0 +1,80 @@
+package schema
+
+import (
+ "fmt"
+ "strings"
+)
+
+// ConstraintError indicates that the passed data violated one or more constraints defined in the schema.
+// The message holds the exact path of the problematic field, as well as a message explaining the error.
+// If this error is not easily understood, please open an issue on the Arcaflow plugin SDK.
+type ConstraintError struct {
+ Message string
+ Path []string
+ Cause error
+}
+
+// Error returns the error message.
+func (c ConstraintError) Error() string {
+ result := fmt.Sprintf("Validation failed for '%s': %s", strings.Join(c.Path, "' -> '"), c.Message)
+ if c.Cause != nil {
+ result += " (" + c.Cause.Error() + ")"
+ }
+ return result
+}
+
+// Unwrap returns the underlying error if any.
+func (c ConstraintError) Unwrap() error {
+ return c.Cause
+}
+
+// NoSuchStepError indicates that the given step is not supported by the plugin.
+type NoSuchStepError struct {
+ Step string
+}
+
+// Error returns the error message.
+func (n NoSuchStepError) Error() string {
+ return fmt.Sprintf("No such step: %s", n.Step)
+}
+
+// BadArgumentError indicates that an invalid configuration was passed to a schema component. The message will
+// explain what exactly the problem is, but may not be able to locate the exact error as the schema may be manually
+// built.
+type BadArgumentError struct {
+ Message string
+ Cause error
+}
+
+// Error returns the error message.
+func (b BadArgumentError) Error() string {
+ result := b.Message
+ if b.Cause != nil {
+ result += " (" + b.Cause.Error() + ")"
+ }
+ return result
+}
+
+// Unwrap returns the underlying error if any.
+func (b BadArgumentError) Unwrap() error {
+ return b.Cause
+}
+
+// UnitParseError indicates that it failed to parse a unit string.
+type UnitParseError struct {
+ Message string
+ Cause error
+}
+
+func (u UnitParseError) Error() string {
+ result := u.Message
+ if u.Cause != nil {
+ result += " (" + u.Cause.Error() + ")"
+ }
+ return result
+}
+
+// Unwrap returns the underlying error if any.
+func (u UnitParseError) Unwrap() error {
+ return u.Cause
+}
diff --git a/schema/types.go b/schema/types.go
new file mode 100644
index 0000000..f4ac8fe
--- /dev/null
+++ b/schema/types.go
@@ -0,0 +1,44 @@
+package schema
+
+// AbstractType describes the common methods all types need to implement.
+type AbstractType interface {
+ ValidateSerialized(data any, path []string) error
+}
+
+// DisplayValue holds the data related to displaying fields.
+type DisplayValue struct {
+ Name *string `json:"name" name:"Name" description:"Short text serving as a name or title for this item." examples:"[\"Fruit\"]" min:"1"`
+ Description *string `json:"description" name:"Description" description:"Description for this item if needed." examples:"[\"Please select the fruit you would like.\"]" min:"1"`
+ Icon *string `json:"icon" name:"Icon" description:"SVG icon for this item. Must have the declared size of 64x64, must not include additional namespaces, and must not reference external resources." examples:"[\"\"]" min:"1"`
+}
+
+type enumValueType interface {
+ int | string
+}
+
+type enumType[T enumValueType] struct {
+}
+
+func (e enumType[T]) ValidateSerialized(data any, path []string) error {
+ return nil
+}
+
+// EnumStringType is an enum type with string values.
+type EnumStringType struct {
+ enumType[string]
+}
+
+// EnumIntType is an enum type with integer values.
+type EnumIntType struct {
+ enumType[int]
+}
+
+// MapKeyType are types that can be used as map keys.
+type MapKeyType interface {
+ int64 | string
+}
+
+// NumberType is a type collection of number types.
+type NumberType interface {
+ int64 | float64
+}
diff --git a/schema/units.go b/schema/units.go
new file mode 100644
index 0000000..8b4712c
--- /dev/null
+++ b/schema/units.go
@@ -0,0 +1,457 @@
+package schema
+
+import (
+ "fmt"
+ "math"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// Unit is a description of a single scale of measurement, such as a "second". If there are multiple scales, such as
+// "minute", "second", etc. then multiple of these unit classes can be composed into units.
+type Unit struct {
+ NameShortSingular string `json:"name_short_singular" name:"Short name (singular)" description:"Short name that can be printed in a few characters, singular form." examples:"[\"B\", \"char\"]"`
+ NameShortPlural string `json:"name_short_plural" name:"Short name (plural)" description:"Short name that can be printed in a few characters, plural form." examples:"[\"B\", \"chars\"]"`
+ NameLongSingular string `json:"name_long_singular" name:"Long name (singular)" description:"Longer name for this unit in singular form." examples:"[\"byte\", \"character\"]"`
+ NameLongPlural string `json:"name_long_plural" name:"Long name (plural)" description:"Longer name for this unit in plural form." examples:"[\"bytes\", \"characters\"]"`
+}
+
+// FormatShortInt formats an amount according to this unit.
+func (u Unit) FormatShortInt(amount int64, displayZero bool) string {
+ return FormatNumberUnitShort(amount, u, displayZero)
+}
+
+// FormatShortFloat formats an amount according to this unit.
+func (u Unit) FormatShortFloat(amount float64, displayZero bool) string {
+ return FormatNumberUnitShort(amount, u, displayZero)
+}
+
+// FormatLongInt formats an amount according to this unit.
+func (u Unit) FormatLongInt(amount int64, displayZero bool) string {
+ return FormatNumberUnitLong(amount, u, displayZero)
+}
+
+// FormatLongFloat formats an amount according to this unit.
+func (u Unit) FormatLongFloat(amount float64, displayZero bool) string {
+ return FormatNumberUnitLong(amount, u, displayZero)
+}
+
+// FormatNumberUnitShort formats a number with a single unit.
+func FormatNumberUnitShort[T NumberType](amount T, unit Unit, displayZero bool) string {
+ var formatString string
+ switch any(amount).(type) {
+ case int64:
+ formatString = "%d"
+ case float64:
+ formatString = "%f"
+ }
+ switch {
+ case amount == 1 || amount == -1:
+ return fmt.Sprintf(formatString, amount) + unit.NameShortSingular
+ case amount != 0:
+ return fmt.Sprintf(formatString, amount) + unit.NameShortPlural
+ case displayZero:
+ return fmt.Sprintf(formatString, amount) + unit.NameShortPlural
+ default:
+ return ""
+ }
+}
+
+// FormatNumberUnitLong formats a number with a single unit.
+func FormatNumberUnitLong[T NumberType](amount T, unit Unit, displayZero bool) string {
+ var formatString string
+ switch any(amount).(type) {
+ case int64:
+ formatString = "%d"
+ case float64:
+ formatString = "%f"
+ }
+ switch {
+ case amount == 1 || amount == -1:
+ return fmt.Sprintf(formatString, amount) + unit.NameLongSingular
+ case amount != 0:
+ return fmt.Sprintf(formatString, amount) + unit.NameLongPlural
+ case displayZero:
+ return fmt.Sprintf(formatString, amount) + unit.NameLongPlural
+ default:
+ return ""
+ }
+}
+
+// Units holds several scales of magnitude of the same unit, for example 5m30s.
+type Units struct {
+ BaseUnit Unit
+ Multipliers map[int64]Unit
+ sortedMultipliersCache []int64
+ reCache *regexp.Regexp
+ reSubExpNames map[string]int
+}
+
+// FormatShortInt formats the passed int according to the unit multipliers.
+func (u *Units) FormatShortInt(data int64) string {
+ return FormatNumberUnitsShort(data, *u)
+}
+
+// FormatShortFloat formats the passed float according to the unit multipliers.
+func (u *Units) FormatShortFloat(data float64) string {
+ return FormatNumberUnitsShort(data, *u)
+}
+
+// FormatLongInt formats the passed int according to the unit multipliers.
+func (u *Units) FormatLongInt(data int64) string {
+ return FormatNumberUnitsLong(data, *u)
+}
+
+// FormatLongFloat formats the passed float according to the unit multipliers.
+func (u *Units) FormatLongFloat(data float64) string {
+ return FormatNumberUnitsLong(data, *u)
+}
+
+// FormatNumberUnitsShort is a generic way to format a number with a unit.
+func FormatNumberUnitsShort[T NumberType](data T, units Units) string {
+ if data == 0 {
+ return FormatNumberUnitShort(data, units.BaseUnit, true)
+ }
+ remainder := data
+ output := ""
+ for _, multiplier := range units.getSortedMultipliersCache() {
+ base := int64(math.Floor(float64(remainder) / float64(multiplier)))
+ remainder -= T(base * multiplier)
+ output += FormatNumberUnitShort(base, units.Multipliers[multiplier], false)
+ }
+ output += FormatNumberUnitShort(remainder, units.BaseUnit, false)
+ return output
+}
+
+// FormatNumberUnitsLong is a generic way to format a number with a unit.
+func FormatNumberUnitsLong[T NumberType](data T, units Units) string {
+ if data == 0 {
+ return FormatNumberUnitLong(data, units.BaseUnit, true)
+ }
+ remainder := data
+ output := ""
+ for _, multiplier := range units.getSortedMultipliersCache() {
+ base := int64(math.Floor(float64(remainder) / float64(multiplier)))
+ remainder -= T(base * multiplier)
+ output += FormatNumberUnitLong(remainder, units.Multipliers[multiplier], false)
+ }
+ output += FormatNumberUnitLong(remainder, units.BaseUnit, false)
+ return output
+}
+
+func (u *Units) getSortedMultipliersCache() []int64 {
+ if u.sortedMultipliersCache == nil {
+ var multipliers []int64
+ for multiplier := range u.Multipliers {
+ multipliers = append(multipliers, multiplier)
+ }
+ sort.SliceStable(multipliers, func(i, j int) bool {
+ return multipliers[i] > multipliers[j]
+ })
+ u.sortedMultipliersCache = multipliers
+ }
+ return u.sortedMultipliersCache
+}
+
+func (u *Units) parse(data string) (any, error) {
+ data = strings.TrimSpace(data)
+ if data == "" {
+ return 0, &UnitParseError{
+ Message: "Empty string cannot be parsed as " + u.BaseUnit.NameLongPlural,
+ }
+ }
+ if u.reCache == nil {
+ u.updateReCache()
+ }
+ match := u.reCache.FindStringSubmatch(data)
+ if match == nil {
+ return u.buildUnitParseError(data)
+ }
+
+ var isFloat bool
+ var floatNumber float64
+ var intNumber int64
+ var err error
+ for _, multiplier := range u.getSortedMultipliersCache() {
+ matchGroupID := u.reSubExpNames[fmt.Sprintf("g%d", multiplier)]
+ result := match[matchGroupID]
+
+ intNumber, floatNumber, isFloat, err = u.handleParseMultiplier(
+ result,
+ multiplier,
+ intNumber,
+ floatNumber,
+ isFloat,
+ )
+ if err != nil {
+ return 0, err
+ }
+ }
+ baseMatchGroup := match[u.reSubExpNames["g1"]]
+ intNumber, floatNumber, isFloat, err = u.handleParseMultiplier(
+ baseMatchGroup,
+ 1,
+ intNumber,
+ floatNumber,
+ isFloat,
+ )
+ if err != nil {
+ return 0, err
+ }
+ if isFloat {
+ return floatNumber, nil
+ }
+ return intNumber, nil
+}
+
+func (u *Units) handleParseMultiplier(
+ result string,
+ multiplier int64,
+ intNumber int64,
+ floatNumber float64,
+ isFloat bool,
+) (int64, float64, bool, error) {
+ if result == "" {
+ return intNumber, floatNumber, isFloat, nil
+ }
+ if strings.Contains(result, ".") {
+ i, err := strconv.ParseFloat(result, 64)
+ if err != nil {
+ return intNumber, floatNumber, isFloat, BadArgumentError{
+ Message: fmt.Sprintf("Failed to parse number as float: %s", result),
+ }
+ }
+ floatNumber += i * float64(multiplier)
+ isFloat = true
+ } else {
+ i, err := strconv.ParseInt(result, 10, 64)
+ if err != nil {
+ return intNumber, floatNumber, isFloat, BadArgumentError{
+ Message: fmt.Sprintf("Failed to parse number as int: %s", result),
+ }
+ }
+ floatNumber += float64(i * multiplier)
+ if !isFloat {
+ intNumber += i * multiplier
+ }
+ }
+ return intNumber, floatNumber, isFloat, nil
+}
+
+func (u *Units) updateReCache() {
+ var parts []string
+ if u.Multipliers != nil {
+ for _, multiplier := range u.getSortedMultipliersCache() {
+ unit := u.Multipliers[multiplier]
+ parts = append(parts, fmt.Sprintf(
+ "(?:|(?P[0-9]+)\\s*(%s|%s|%s|%s))",
+ regexp.QuoteMeta(fmt.Sprintf("%d", multiplier)),
+ regexp.QuoteMeta(unit.NameShortSingular),
+ regexp.QuoteMeta(unit.NameShortPlural),
+ regexp.QuoteMeta(unit.NameLongSingular),
+ regexp.QuoteMeta(unit.NameLongPlural),
+ ))
+ }
+ }
+ parts = append(parts, fmt.Sprintf(
+ "(?:|(?P[0-9]+(|.[0-9]+))\\s*(|%s|%s|%s|%s))",
+ regexp.QuoteMeta(u.BaseUnit.NameShortSingular),
+ regexp.QuoteMeta(u.BaseUnit.NameShortPlural),
+ regexp.QuoteMeta(u.BaseUnit.NameLongSingular),
+ regexp.QuoteMeta(u.BaseUnit.NameLongPlural),
+ ))
+ regex := "^\\s*" + strings.Join(parts, "\\s*") + "\\s*$"
+ u.reCache = regexp.MustCompile(regex)
+ u.reSubExpNames = map[string]int{}
+ for i, subExpName := range u.reCache.SubexpNames() {
+ u.reSubExpNames[subExpName] = i
+ }
+}
+
+func (u *Units) buildUnitParseError(data string) (any, error) {
+ validUnits := []string{
+ u.BaseUnit.NameShortSingular,
+ u.BaseUnit.NameShortPlural,
+ u.BaseUnit.NameLongSingular,
+ u.BaseUnit.NameLongPlural,
+ }
+ for _, multiplier := range u.getSortedMultipliersCache() {
+ validUnits = append(
+ validUnits,
+ u.Multipliers[multiplier].NameShortSingular,
+ u.Multipliers[multiplier].NameShortPlural,
+ u.Multipliers[multiplier].NameLongSingular,
+ u.Multipliers[multiplier].NameLongPlural,
+ )
+ }
+ return 0, UnitParseError{
+ Message: fmt.Sprintf(
+ "Cannot parse '%s' as '%s': invalid format, valid unit types are: '%s",
+ data,
+ u.BaseUnit.NameLongPlural,
+ strings.Join(validUnits, "', '"),
+ ),
+ }
+}
+
+// ParseInt parses a string into an integer.
+func (u *Units) ParseInt(data string) (int64, error) {
+ result, err := u.parse(data)
+ if err != nil {
+ return 0, err
+ }
+ if i, ok := result.(int64); ok {
+ return i, nil
+ }
+ return 0, BadArgumentError{
+ Message: fmt.Sprintf("Failed to parse %s as an integer, float found.", data),
+ }
+}
+
+// ParseFloat parses a string into a floating point number.
+func (u *Units) ParseFloat(data string) (float64, error) {
+ result, err := u.parse(data)
+ if err != nil {
+ return 0, err
+ }
+ if i, ok := result.(int64); ok {
+ return float64(i), nil
+ }
+ return result.(float64), nil
+}
+
+// UnitBytes is scaling, byte-based unit.
+var UnitBytes = Units{
+ BaseUnit: Unit{
+ "B",
+ "B",
+ "byte",
+ "bytes",
+ },
+ Multipliers: map[int64]Unit{
+ 1024: {
+ "kB",
+ "kB",
+ "kilobyte",
+ "kilobytes",
+ },
+ 1048576: {
+ "MB",
+ "MB",
+ "megabyte",
+ "megabytes",
+ },
+ 1073741824: {
+ "GB",
+ "GB",
+ "gigabyte",
+ "gigabytes",
+ },
+ 1099511627776: {
+ "TB",
+ "TB",
+ "terabyte",
+ "terabytes",
+ },
+ 1125899906842624: {
+ "PB",
+ "PB",
+ "petabyte",
+ "petabytes",
+ },
+ },
+}
+
+// UnitDurationNanoseconds is a nanosecond-based unit for time durations.
+var UnitDurationNanoseconds = Units{
+ BaseUnit: Unit{
+ "ns",
+ "ns",
+ "nanosecond",
+ "nanoseconds",
+ },
+ Multipliers: map[int64]Unit{
+ 1000: {
+ "ms",
+ "ms",
+ "microsecond",
+ "microseconds",
+ },
+ 1000000: {
+ "s",
+ "s",
+ "second",
+ "seconds",
+ },
+ 60000000: {
+ "m",
+ "m",
+ "minute",
+ "minutes",
+ },
+ 3600000000: {
+ "H",
+ "H",
+ "hour",
+ "hours",
+ },
+ 86400000000: {
+ "d",
+ "d",
+ "day",
+ "days",
+ },
+ },
+}
+
+// UnitDurationSeconds is a second-based unit for time durations.
+var UnitDurationSeconds = Units{
+ BaseUnit: Unit{
+ "s",
+ "s",
+ "second",
+ "seconds",
+ },
+ Multipliers: map[int64]Unit{
+ 60: {
+ "m",
+ "m",
+ "minute",
+ "minutes",
+ },
+ 3600: {
+ "H",
+ "H",
+ "hour",
+ "hours",
+ },
+ 86400: {
+ "d",
+ "d",
+ "day",
+ "days",
+ },
+ },
+}
+
+// UnitCharacters is a single unit for characters.
+var UnitCharacters = Units{
+ BaseUnit: Unit{
+ "char",
+ "chars",
+ "character",
+ "characters",
+ },
+}
+
+// UnitPercentage is a single unit for percentages.
+var UnitPercentage = Units{
+ BaseUnit: Unit{
+ "%",
+ "%",
+ "percent",
+ "percent",
+ },
+}
diff --git a/schema/units_test.go b/schema/units_test.go
new file mode 100644
index 0000000..e174ac9
--- /dev/null
+++ b/schema/units_test.go
@@ -0,0 +1,42 @@
+package schema_test
+
+import (
+ "testing"
+
+ "go.flow.arcalot.io/pluginsdk/schema"
+)
+
+func TestUnitsParseInt(t *testing.T) {
+ testMatrix := map[string]struct {
+ input string
+ units schema.Units
+ expected int64
+ }{
+ "5m5s": {
+ "5m5s",
+ schema.UnitDurationNanoseconds,
+ 305000000,
+ },
+ "1%": {
+ "1%",
+ schema.UnitPercentage,
+ 1,
+ },
+ }
+
+ for testCase, testData := range testMatrix {
+ t.Run(testCase, func(t *testing.T) {
+ result, err := testData.units.ParseInt(testData.input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result != testData.expected {
+ t.Fatalf("Result mismatch, expected: %d, got: %d", testData.expected, result)
+ }
+ formatted := testData.units.FormatShortInt(result)
+ if formatted != testData.input {
+ t.Fatalf("Formatted result doesn't match input, expected: %s, got: %s", testData.input, formatted)
+ }
+ })
+ }
+}