diff --git a/.gitignore b/.gitignore index 3b735ec..b9df791 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ # Go workspace file go.work +/haproxytimeout diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e99c62 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +DATE := $(shell date --iso-8601=seconds) +GOVERSION := $(shell go version) +COMMIT := $(shell git describe --tags --abbrev=8 --dirty --always --long) + +PREFIX := main +LDFLAGS := -X '$(PREFIX).buildCommit=$(COMMIT)' -X '$(PREFIX).buildDate=$(DATE)' -X '$(PREFIX).buildGoVersion=$(GOVERSION)' + +build: test + go build -ldflags "$(LDFLAGS)" -o ./haproxytimeout ./cmd/haproxytimeout + +test: + go test ./... + +clean: + $(RM) ./haproxytimeout + +.PHONY: build test clean diff --git a/README.md b/README.md index 3843f7e..3999a24 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,78 @@ -# parse time durations, with support for days +# Parse time durations, with support for days -This is a Go library that parses duration strings in a format similar -to time.ParseDuration, with the additional capability of handling -duration strings specifying a number of days ("d"). This functionality -is not available in the built-in time.ParseDuration function. It also -differs by not accepting negative values. This package was primarily -created for validating HAProxy timeout values. +A Go library to parse time duration values similar to +time.ParseDuration, but with extended functionality. In addition to +the standard duration units like "h", "m", and "s", it also supports +days (represented as "d"), which are unavailable in the built-in +time.ParseDuration function. All durations parsed by this package are +ensured to be non-negative and won't overflow the time.Duration value +type. Furthermore, the parsed value cannot exceed HAProxy's maximum +timeout value, ensuring compatibility with HAProxy's configuration +constraints. -The CLI utility `haproxy-timeout-checker` is an example of using the -package. It validates the time duration using `ParseDuration` and also -checks to see if the duration exceeds HAProxy's maximum. +The CLI utility `haproxytimeout` is an example of using the package. +The utility parses a duration string, printing the parsed value as a +pair; "human-readable duration" -> "millisecond duration". ```console -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "9223372036s" -duration 9223372036000ms exceeds HAProxy's maximum value of 2147483647ms +$ go build ./cmd/haproxytimeout +haproxytimeout - Convert human-readable durations to millisecond format -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "2147483647ms" -2147483647 +Usage: + haproxytimeout [-m] [-j] -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "2147483648ms" -duration 2147483648ms exceeds HAProxy's maximum value of 2147483647ms +Options: + -m: Print the maximum duration HAProxy can tolerate and exit. + -j: Print results in JSON format. (e.g., haproxytimeout -j | jq .milliseconds) -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d" -86400000 +Input Assumptions: + If the input is a plain number (or a decimal without a unit), it's + assumed to be in milliseconds. For example: 'haproxytimeout 1500' + is assumed to be '1500ms'. -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d 1s" -86401000 +Output Format: + The output maps the human-readable format to the milliseconds + format. For example: 'haproxytimeout 2h30m' will output '2h30m -> + 9000000ms'. The right-hand side is always in milliseconds, making it + suitable for direct use in a haproxy.cfg file. -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d 3h 10m 20s 100ms 9999us" -97820109 +Available units: + d days + h: hours + m: minutes + s: seconds + ms: milliseconds + us: microseconds -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go 5000 -5000 +Examples: + haproxytimeout 2h30m -> Convert 2 hours 30 minutes to milliseconds + haproxytimeout 1500ms -> Convert 1500 milliseconds to a human-readable format + haproxytimeout -j 1h17m -> Get JSON output for 1h 17minutes + haproxytimeout -m -> Show the maximum duration HAProxy can tolerate +``` -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "5000 999999ms" -5000 999999ms - ^ -error: invalid unit order +## Examples -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d 1f" -1d 1f - ^ -error: invalid unit +```sh +$ ./haproxytimeout 30m +30m -> 1800000ms +``` -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d 1d" -1d 1d - ^ -error: invalid unit order +```sh +$ ./haproxytimeout 1d12h +1d12h -> 129600000ms +``` -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d 5m 1230ms" -86701230 +```sh +# Print maximum value allowed by HAProxy. +$ ./haproxytimeout -m +24d20h31m23s647ms -> 2147483647ms +``` -# Note: Spaces are optional. -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "1d5m" -86700000 +```sh +% ./haproxytimeout -j 17m +{"human_readable":"17m","milliseconds":1020000} -$ go run cmd/haproxy-timeout-checker/haproxy-timeout-checker.go "9223372037s" -9223372037s - ^ -error: underflow +% ./haproxytimeout -j 17m | jq .milliseconds +1020000 ``` diff --git a/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go b/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go deleted file mode 100644 index 93fec65..0000000 --- a/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/frobware/haproxytime" -) - -const HAProxyMaxTimeoutValue = 2147483647 * time.Millisecond - -func main() { - if len(os.Args) < 2 { - fmt.Println(`usage: `) - os.Exit(1) - } - - duration, position, err := haproxytime.ParseDuration(os.Args[1]) - if err != nil { - fmt.Fprintln(os.Stderr, os.Args[1]) - fmt.Fprintf(os.Stderr, "%"+fmt.Sprint(position)+"s", "") - fmt.Fprintln(os.Stderr, "^") - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) - } - - if duration.Milliseconds() > HAProxyMaxTimeoutValue.Milliseconds() { - fmt.Fprintf(os.Stderr, "duration %vms exceeds HAProxy's maximum value of %vms\n", duration.Milliseconds(), HAProxyMaxTimeoutValue.Milliseconds()) - os.Exit(1) - } - - fmt.Println(duration.Milliseconds()) -} diff --git a/cmd/haproxytimeout/haproxytimeout.go b/cmd/haproxytimeout/haproxytimeout.go new file mode 100644 index 0000000..fcfa2c8 --- /dev/null +++ b/cmd/haproxytimeout/haproxytimeout.go @@ -0,0 +1,325 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/frobware/haproxytime" +) + +// These variable are populated at build time using linker flags, and +// the overall build version is retrieved via the BuildVersion +// function. +var ( + buildCommit string + buildDate string + buildGoVersion string +) + +// BuildVersion is a function variable that returns the current build +// version. By default, it returns the value of the unexported +// 'version' variable, which is set during build time. This variable +// is designed to be overridden for testing purposes. +var BuildVersion = func() string { + return fmt.Sprintf("%s (%s) %s", buildCommit, buildDate, buildGoVersion) +} + +var Usage = ` +haproxytimeout - Convert human-readable time durations to millisecond format + +General Usage: + haproxytimeout [-help] [-v] + haproxytimeout [-h] [-m] [] + +Usage: + -help Show usage information + -v Show version information + -h Print duration value in a human-readable format + -m Print the maximum HAProxy timeout value + : value to convert. If omitted, will read from stdin. + +The flags [-help] and [-v] are mutually exclusive with any other +options or duration input. + +Available units for time durations: + d days + h: hours + m: minutes + s: seconds + ms: milliseconds + us: microseconds + +A duration value without a unit defaults to milliseconds. + +Examples: + haproxytimeout -m -> Print the maximum HAProxy duration. + haproxytimeout 2h30m5s -> Convert duration to milliseconds. + haproxytimeout -h 4500000 -> Convert 4500000ms to a human-readable format. + echo 150s | haproxytimeout -> Convert 150 seconds to milliseconds.`[1:] + +// printErrorWithPosition writes an error message along with its +// position in the input string to the given Writer. The function +// prints the error, the input string, and a caret '^' pointing to the +// position where the error occurred. +// +// Parameters: +// - w: the io.Writer to which the output is written +// - input: the string that produced the error +// - err: the error to be displayed +// - position: the 1-based index at which the error occurred within the input +// +// Example: +// +// If the input is "24d20h31m23s647msO000us" and the error +// occurred at position 18, the output would be: +// +// syntax error at position 18: invalid number +// 24d20h31m23s647msO000us +// ^ +func printErrorWithPosition(w io.Writer, input string, err error, position int) { + fmt.Fprintln(w, err) + fmt.Fprintln(w, input) + fmt.Fprintf(w, "%"+fmt.Sprint(position)+"s", "") + fmt.Fprintln(w, "^") +} + +// formatDuration takes a time.Duration value and returns a +// human-readable string representation. The string breaks down the +// duration into days, hours, minutes, seconds, milliseconds. Each +// unit of time will only be included in the output if its value is +// greater than zero. +// +// Example: +// +// Input: 36h12m15s +// Output: "1d12h12m15s" +// +// Input: 2m15s300ms +// Output: "2m15s300ms" +// +// Parameters: +// - duration: the time.Duration value to be formatted +// +// Returns: +// - A string representing the human-readable format of the input +// duration. +func formatDuration(duration time.Duration) string { + if duration == 0 { + return "0ms" + } + + const Day = time.Hour * 24 + + days := duration / Day + duration -= days * Day + + hours := duration / time.Hour + duration -= hours * time.Hour + + minutes := duration / time.Minute + duration -= minutes * time.Minute + + seconds := duration / time.Second + duration -= seconds * time.Second + + milliseconds := duration / time.Millisecond + duration -= milliseconds * time.Millisecond + + var result string + if days > 0 { + result += fmt.Sprintf("%dd", days) + } + if hours > 0 { + result += fmt.Sprintf("%dh", hours) + } + if minutes > 0 { + result += fmt.Sprintf("%dm", minutes) + } + if seconds > 0 { + result += fmt.Sprintf("%ds", seconds) + } + if milliseconds > 0 { + result += fmt.Sprintf("%dms", milliseconds) + } + + return result +} + +// output writes a time.Duration value to the given io.Writer. The +// format of the output depends on the printHuman flag. +// +// Parameters: +// - w: the io.Writer to which the output is written +// - duration: the time.Duration value to be displayed +// - printHuman: a boolean flagl; if true display in human-readable format +// +// If printHuman is true, the duration is formatted using the +// formatDuration function, which breaks down the duration into units +// like days, hours, minutes, etc., and displays it accordingly. +// +// If printHuman is false, the duration is simply displayed as the +// number of milliseconds, followed by the unit "ms". +// +// Examples: +// - With printHuman=true and duration=86400000ms, the output will be "1d". +// - With printHuman=false and duration=86400000ms, the output will be "86400000ms". +func output(w io.Writer, duration time.Duration, printHuman bool) { + if printHuman { + fmt.Fprintln(w, formatDuration(duration)) + } else { + fmt.Fprintf(w, "%vms\n", duration.Milliseconds()) + } +} + +// printPositionalError formats and outputs an error message to the +// provided io.Writer, along with the position at which the error +// occurred in the input argument. It supports haproxytime.SyntaxError +// and haproxytime.OverflowError types, which contain positional +// information. +// +// Parameters: +// - w: the io.Writer to output the error message, usually os.Stderr +// - err: the error that occurred, expected to be of type *haproxytime.SyntaxError or *haproxytime.OverflowError +// - arg: the input argument string where the error occurred +// +// The function first tries to cast the error to either +// haproxytime.SyntaxError or haproxytime.OverflowError. If +// successful, it prints the error message along with the position at +// which the error occurred, using printErrorWithPosition function. +// +// Example: +// +// Given an OverflowError with Position=18 and +// arg="24d20h31m23s647ms1000us", it would print: +// +// overflow error at position 18: 1000 exceeds max duration +// 24d20h31m23s647ms1000us +// ^ +func printPositionalError(w io.Writer, err error, arg string) { + var syntaxErr *haproxytime.SyntaxError + var overflowErr *haproxytime.OverflowError + + switch { + case errors.As(err, &syntaxErr): + printErrorWithPosition(w, arg, err, syntaxErr.Position()) + case errors.As(err, &overflowErr): + printErrorWithPosition(w, arg, err, overflowErr.Position()) + } +} + +// readAll reads all available bytes from the given io.Reader into a +// string. It also trims any trailing newline characters. If an error +// occurs during the read operation, it returns an empty string and +// the error wrapped with additional context. +func readAll(rdr io.Reader) (string, error) { + inputBytes, err := io.ReadAll(rdr) + if err != nil { + return "", fmt.Errorf("error reading: %w", err) + } + return strings.TrimRight(string(inputBytes), "\n"), nil +} + +// getInputSource determines the source of the input for parsing the +// duration. It checks if there are any remaining command-line +// arguments, and if so, uses the first one as the input string. +// Otherwise, it reads from the provided Reader. +// +// Parameters: +// - rdr: An io.Reader from which to read input if remainingArgs is +// empty +// - remainingArgs: A slice of remaining command-line arguments +// +// Returns: +// - The input string to be parsed +// - An error if reading from stdin fails +func getInputSource(rdr io.Reader, remainingArgs []string) (string, error) { + if len(remainingArgs) > 0 { + return remainingArgs[0], nil + } + return readAll(rdr) +} + +// ConvertDuration is the primary logic function for the +// haproxytimeout tool. It parses command-line flags, reads input for +// a duration string (either from arguments or stdin), converts it +// into a Go time.Duration object, and then outputs the result. +// +// Parameters: +// - stdin: the io.Reader from which input will be read. +// - stdout: the io.Writer to which normal output will be written. +// - stderr: the io.Writer to which error messages will be written. +// - args: command-line arguments +// - errorHandling: the flag.ErrorHandling strategy for parsing flags +// +// Returns: +// +// - 0 for successful execution, 1 for errors +// +// Flags supported: +// - help: Show usage information +// - v: Show version information +// - h: Output duration in a human-readable format +// - m: Output the maximum HAProxy duration +// +// If an error occurs, the function writes the error message to stderr +// and returns 1. Otherwise, it writes the converted or maximum +// duration to stdout and returns 0. +func ConvertDuration(stdin io.Reader, stdout, stderr io.Writer, args []string) int { + fs := flag.NewFlagSet("haproxytimeout", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var showHelp, showVersion, printHuman, printMax bool + + fs.BoolVar(&printHuman, "h", false, "Print duration value in a human-readable format") + fs.BoolVar(&printMax, "m", false, "Print the maximum HAProxy timeout value") + fs.BoolVar(&showHelp, "help", false, "Show usage information") + fs.BoolVar(&showVersion, "v", false, "Show version information") + + if err := fs.Parse(args); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + + if showHelp { + fmt.Fprintln(stderr, Usage) + return 1 + } + + if showVersion { + fmt.Fprintf(stderr, "haproxytimeout: %s\n", BuildVersion()) + return 0 + } + + if printMax { + output(stdout, haproxytime.MaxTimeout, printHuman) + return 0 + } + + input, err := getInputSource(stdin, fs.Args()) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + + duration, err := haproxytime.ParseDuration(input, haproxytime.UnitMillisecond, haproxytime.ParseModeMultiUnit) + if err != nil { + if len(fs.Args()) > 0 { + printPositionalError(stderr, err, fs.Args()[0]) + } else { + fmt.Fprintln(stderr, err) + } + return 1 + } + + output(stdout, duration, printHuman) + return 0 +} + +func main() { + os.Exit(ConvertDuration(os.Stdin, os.Stdout, os.Stderr, os.Args[1:])) +} diff --git a/cmd/haproxytimeout/haproxytimeout_test.go b/cmd/haproxytimeout/haproxytimeout_test.go new file mode 100644 index 0000000..cfb10e5 --- /dev/null +++ b/cmd/haproxytimeout/haproxytimeout_test.go @@ -0,0 +1,186 @@ +package main_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + cmd "github.com/frobware/haproxytime/cmd/haproxytimeout" +) + +type errorReader struct{} + +func (er *errorReader) Read([]byte) (n int, err error) { + return 0, errors.New("simulated read error") +} + +type emptyStringReader struct { + read bool +} + +func (er *emptyStringReader) Read(p []byte) (n int, err error) { + if er.read { + return 0, io.EOF + } + er.read = true + return 0, nil +} + +func TestConvertDuration(t *testing.T) { + // We need a constant build version information for the tests + // to pass. + originalVersion := cmd.BuildVersion + defer func() { cmd.BuildVersion = originalVersion }() + cmd.BuildVersion = func() string { + return "v0.0.0" + } + + tests := []struct { + description string + args []string + stdin io.Reader + expectedExit int + expectedStdout string + expectedStderr string + }{{ + description: "Test version flag", + args: []string{"-v"}, + expectedExit: 0, + expectedStdout: "", + expectedStderr: `haproxytimeout: v0.0.0`, + }, { + description: "Test -m flag", + args: []string{"-m"}, + expectedExit: 0, + expectedStdout: "2147483647ms", + expectedStderr: "", + }, { + description: "Test -h flag", + args: []string{"-h", "2147483647ms"}, + expectedExit: 0, + expectedStdout: "24d20h31m23s647ms", + expectedStderr: "", + }, { + description: "Test -m -h combined", + args: []string{"-m", "-h"}, + expectedExit: 0, + expectedStdout: "24d20h31m23s647ms", + expectedStderr: "", + }, { + description: "number of milliseconds in a day from args", + args: []string{"86400000"}, + expectedExit: 0, + expectedStdout: "86400000ms", + expectedStderr: "", + }, { + description: "number of milliseconds in a day from stdin", + stdin: strings.NewReader("1d\n"), + expectedExit: 0, + expectedStdout: "86400000ms", + expectedStderr: "", + }, { + description: "number of milliseconds in a day with human-readable output", + args: []string{"-h", "86400000"}, + expectedExit: 0, + expectedStdout: "1d", + expectedStderr: "", + }, { + description: "1d as milliseconds", + args: []string{"1d"}, + expectedExit: 0, + expectedStdout: "86400000ms", + expectedStderr: "", + }, { + description: "Almost the very very max", + args: []string{"-h", "24d20h31m23s646ms1000us"}, + expectedExit: 0, + expectedStdout: "24d20h31m23s647ms", + expectedStderr: "", + }, { + description: "Test help flag", + args: []string{"-help"}, + expectedExit: 1, + expectedStdout: "", + expectedStderr: cmd.Usage, + }, { + description: "Test single invalid flag", + args: []string{"-z"}, + expectedExit: 1, + expectedStdout: "", + expectedStderr: "flag provided but not defined: -z", + }, { + description: "Test mix of valid and invalid flags", + args: []string{"-h", "-z", "100ms"}, + expectedExit: 1, + expectedStdout: "", + expectedStderr: "flag provided but not defined: -z", + }, { + description: "Test syntax error reporting from args", + args: []string{"24d20h31m23s647msO000us"}, + expectedExit: 1, + expectedStdout: "", + expectedStderr: "syntax error at position 18: invalid number\n24d20h31m23s647msO000us\n ^", + }, { + description: "Test syntax error reporting from stdin", + stdin: strings.NewReader("24d20h31m23s647msO000us\n"), + expectedExit: 1, + expectedStdout: "", + expectedStderr: "syntax error at position 18: invalid number", + }, { + description: "Test overflow error reporting from args", + args: []string{"24d20h31m23s647ms1000us"}, + expectedExit: 1, + expectedStdout: "", + expectedStderr: "overflow error at position 18: 1000 exceeds max duration\n24d20h31m23s647ms1000us\n ^", + }, { + description: "Test overflow error reporting from stdin", + stdin: strings.NewReader("24d20h31m23s647ms1000us\n"), + expectedExit: 1, + expectedStdout: "", + expectedStderr: "overflow error at position 18: 1000 exceeds max duration", + }, { + description: "Test simulated reading failure", + stdin: &errorReader{}, + expectedExit: 1, + expectedStdout: "", + expectedStderr: "error reading: simulated read error", + }, { + description: "Test empty string from stdin", + stdin: &emptyStringReader{}, + expectedExit: 0, + expectedStdout: "0ms", + expectedStderr: "", + }, { + description: "Test empty string from stdin with -h flag", + args: []string{"-h"}, + stdin: &emptyStringReader{}, + expectedExit: 0, + expectedStdout: "0ms", + expectedStderr: "", + }} + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := cmd.ConvertDuration(tc.stdin, stdout, stderr, tc.args) + + if exitCode != tc.expectedExit { + t.Errorf("Expected exit code %d, but got %d", tc.expectedExit, exitCode) + } + + actualStdout := strings.TrimSuffix(stdout.String(), "\n") + if actualStdout != tc.expectedStdout { + t.Errorf("Expected stdout:\n<<<%s>>>\nBut got:\n<<<%s>>>", tc.expectedStdout, actualStdout) + } + + actualStderr := strings.TrimSuffix(stderr.String(), "\n") + if actualStderr != tc.expectedStderr { + t.Errorf("Expected stderr:\n<<<%s>>>\nBut got:\n<<<%s>>>", tc.expectedStderr, actualStderr) + } + }) + } +} diff --git a/duration_parser.go b/duration_parser.go index c8c4375..b3f2762 100644 --- a/duration_parser.go +++ b/duration_parser.go @@ -1,248 +1,415 @@ -// Package haproxytime provides a utility for parsing -// duration strings in a format similar to time.ParseDuration, with -// the additional capability of handling duration strings specifying a -// number of days (d). This functionality is not available in the -// built-in time.ParseDuration function. It also returns an error if -// any duration is negative. -// -// This package was primarily created for validating HAProxy timeout -// values. -// -// For example, an input of "2d 4h" would be parsed into a -// time.Duration representing two days and four hours. +// Package haproxytime offers a specialised function for parsing +// duration strings with extended capabilities compared to the +// standard time.ParseDuration function. In addition to recognising +// time units such as "h", "m", and "s", this package also supports +// day durations, represented as "d". Furthermore, it optionally +// allows parsing of durations with multiple units in a single string, +// such as "1d5m200ms". +// +// The parsed durations are ensured to be non-negative and are +// constrained by HAProxy's MaxTimeout configuration to ensure +// compatibility with HAProxy settings. package haproxytime import ( "errors" "fmt" "strconv" + "strings" "time" "unicode" ) -var ( - // errParseDurationOverflow is triggered when a duration value - // exceeds the permissible maximum limit, leading to an - // overflow. - errParseDurationOverflow = fmt.Errorf("overflow") - - // errParseDurationUnderflow is triggered when a duration - // value falls below the acceptable minimum limit, resulting - // in an underflow. - errParseDurationUnderflow = fmt.Errorf("underflow") - - // strToUnit is a map that associates string representations - // of time units with their corresponding unit constants. It - // serves as a lookup table to convert string units to their - // respective durations. - strToUnit = map[string]unit{ - "d": day, - "h": hour, - "m": minute, - "s": second, - "ms": millisecond, - "us": microsecond, - } +// These constants represent different units of time used in the +// duration parsing process. They are ordered in decreasing magnitude, +// from UnitDay to UnitMicrosecond. The zero value of the unit type is +// reserved to represent an invalid unit. +const ( + UnitDay Unit = iota + 1 + UnitHour + UnitMinute + UnitSecond + UnitMillisecond + UnitMicrosecond +) - // unitToDuration is a map that correlates time duration units - // with their corresponding durations in time.Duration format. - unitToDuration = map[unit]time.Duration{ - day: 24 * time.Hour, - hour: time.Hour, - minute: time.Minute, - second: time.Second, - millisecond: time.Millisecond, - microsecond: time.Microsecond, - } +const ( + // ParseModeMultiUnit allows for multiple units to be + // specified together in the duration string, e.g., "1d2h3m". + ParseModeMultiUnit ParseMode = iota + 1 + + // ParseModeSingleUnit permits only a single unit type to be + // present in the duration string. Any subsequent unit types + // will result in an error. For instance, "1d" would be valid, + // but "1d2h" would not. + ParseModeSingleUnit + + // MaxTimeout represents the maximum timeout duration, + // equivalent to the maximum signed 32-bit integer value in + // milliseconds. It can be used as an upper limit when setting + // or comparing timeout durations to ensure they don't exceed + // this predefined threshold. + MaxTimeout = 2147483647 * time.Millisecond ) -// unit is used to represent different time units (day, hour, minute, +// ParseMode defines the behavior for interpreting units in a duration +// string. It decides how many units can be accepted when parsing. +type ParseMode int + +// Unit is used to represent different time units (day, hour, minute, // second, millisecond, microsecond) in numerical form. The zero value -// of 'unit' represents an invalid time unit. -type unit uint +// represents an invalid time unit. +type Unit uint -// These constants represent different units of time used in the -// duration parsing process. They are ordered in decreasing magnitude -// from day to microsecond. The zero value of 'unit' type is reserved -// to represent an invalid unit. -const ( - day unit = iota + 1 - hour - minute - second - millisecond - microsecond -) +// unitInfo defines a time unit's symbol and its corresponding +// duration in time.Duration units. +type unitInfo struct { + // symbol is the string representation of the time unit, e.g., + // "h" for hour. + symbol string -type token struct { - value int64 - unit + // duration represents the length of time that one unit of + // this type equates to, measured in Go's time.duration units. duration time.Duration } -type parser struct { - tokens []*token - current int // current offset in input - position int // parse error location in input +// SyntaxError represents an error that occurs during the parsing of a +// duration string. It provides details about the specific nature of +// the error and the position in the string where the error was +// detected. +type SyntaxError struct { + // cause specifies the type of syntax error encountered, such + // as InvalidNumber, InvalidUnit, InvalidUnitOrder, or + // ExtraneousCharactersInSingleUnitMode. + cause SyntaxErrorCause + + // position represents the location in the input string where + // the error was detected. The position is 0-indexed. + position int } -func (p *parser) parse(input string) error { - p.tokens = make([]*token, 0, len(input)/2) - p.skipWhitespace(input) +// SyntaxErrorCause represents the cause of a syntax error during +// duration parsing. It discriminates between different kinds of +// syntax errors to aid in error handling and debugging. +type SyntaxErrorCause int - for p.current < len(input) { - p.position = p.current - token, err := p.nextToken(input) - if err != nil { - return err - } - if err := p.validateToken(token); err != nil { - return err - } - p.tokens = append(p.tokens, token) - p.skipWhitespace(input) - } +const ( + // InvalidNumber indicates that a provided number in the + // duration string is invalid or cannot be interpreted. + InvalidNumber SyntaxErrorCause = iota + 1 + + // InvalidUnit signifies that an unrecognised or unsupported + // unit is used in the duration string. + InvalidUnit - return nil + // InvalidUnitOrder denotes an error when units in the + // duration string are not in decreasing order of magnitude + // (e.g., specifying minutes before hours). + InvalidUnitOrder + + // ExtraneousCharactersInSingleUnitMode indicates that + // unexpected characters were encountered beyond the first + // valid duration when parsing in ParseModeSingleUnit. This + // occurs when multiple unit-value pairs or extraneous + // characters are found, which are not permitted in this mode. + ExtraneousCharactersInSingleUnitMode +) + +// OverflowError represents an error that occurs when a parsed value +// exceeds the allowable range, leading to an overflow condition. +type OverflowError struct { + // position represents the location in the input string where + // the error was detected. The position is 0-indexed. + position int + + // number is the substring of the input string that represents + // the numeric value causing the overflow. This provides a + // direct reference to the original representation of the + // number in the input. + number string } -func (p *parser) validateToken(token *token) error { - if len(p.tokens) > 0 { - prevUnit := p.tokens[len(p.tokens)-1].unit - if prevUnit >= token.unit { - return fmt.Errorf("invalid unit order") - } +var ( + // unitProperties maps Units to their details. + unitProperties = map[Unit]unitInfo{ + UnitDay: {symbol: "d", duration: 24 * time.Hour}, + UnitHour: {symbol: "h", duration: time.Hour}, + UnitMinute: {symbol: "m", duration: time.Minute}, + UnitSecond: {symbol: "s", duration: time.Second}, + UnitMillisecond: {symbol: "ms", duration: time.Millisecond}, + UnitMicrosecond: {symbol: "us", duration: time.Microsecond}, + } + + // symbolToUnit maps time unit symbols to their corresponding + // Units. + symbolToUnit = map[string]Unit{ + "d": UnitDay, + "h": UnitHour, + "m": UnitMinute, + "s": UnitSecond, + "ms": UnitMillisecond, + "us": UnitMicrosecond, } - if token.duration < 0 { - return errParseDurationUnderflow +) + +// consumeUnit scans the input string starting from the given position +// and attempts to extract a known time unit symbol. It first looks +// for multi-character symbols like "ms" and "us". If none of the +// multi-character symbols are found, it returns the single character +// at the current position as the consumed unit symbol. +// +// This function is exclusively called by ParseDuration to ensure that +// it is never called when there is no remaining input. +// +// Parameters: +// - input: The string being parsed. +// - start: The starting position for scanning the string. +// +// Returns: +// - A string representation of the found unit symbol. +// - The new position in the string after the last character of the unit symbol. +func consumeUnit(input string, start int) (string, int) { + current := start + for _, symbol := range []string{"ms", "us"} { + if strings.HasPrefix(input[current:], symbol) { + return symbol, current + len(symbol) + } } - return nil + + return string(input[current]), current + 1 } -func (p *parser) nextToken(input string) (*token, error) { - p.position = p.current - value, err := p.consumeNumber(input) - if err != nil { - return nil, err +// consumeNumber scans the input string starting from the given +// position and attempts to extract a contiguous sequence of numeric +// characters (digits). +// +// This function is exclusively called by ParseDuration to ensure that +// it is never called when there is no remaining input. +// +// Parameters: +// - input: The string being parsed. +// - start: The starting position for scanning the string. +// +// Returns: +// - A string representation of the contiguous sequence of digits. +// - The new position in the string after the last digit. +func consumeNumber(input string, start int) (string, int) { + current := start + for current < len(input) && unicode.IsDigit(rune(input[current])) { + current++ } - p.position = p.current - unitStr := p.consumeUnit(input) - if unitStr == "" { - unitStr = "ms" + if start == current { + return "", current } + return input[start:current], current +} + +// Is checks whether the provided target error matches the SyntaxError +// type. This method facilitates the use of the errors.Is function for +// matching against SyntaxError. +// +// Example: +// +// if errors.Is(err, &haproxytime.SyntaxError{}) { +// // handle SyntaxError +// } +func (e *SyntaxError) Is(target error) bool { + var syntaxError *SyntaxError + return errors.As(target, &syntaxError) +} - unit, found := strToUnit[unitStr] - if !found { - return nil, errors.New("invalid unit") +// Position returns the position in the input string where the +// SyntaxError occurred. The position is 0-based, meaning that the +// first character in the input string is at position 0. +func (e *SyntaxError) Position() int { + return e.position +} + +// Error implements the error interface for ParseError. It provides a +// formatted error message detailing the position (1-index based) and +// the nature of the parsing error. +func (e *SyntaxError) Error() string { + var msg string + switch e.cause { + case InvalidNumber: + msg = "invalid number" + case InvalidUnit: + msg = "invalid unit" + case InvalidUnitOrder: + msg = "invalid unit order" + case ExtraneousCharactersInSingleUnitMode: + msg = "extraneous characters in single unit mode" } + return fmt.Sprintf("syntax error at position %d: %v", e.position+1, msg) +} - return &token{value, unit, time.Duration(value) * unitToDuration[unit]}, nil +// Cause returns the specific cause of the SyntaxError. The cause +// provides details on the type of syntax error encountered, such as +// InvalidNumber, InvalidUnit, InvalidUnitOrder, or +// ExtraneousCharactersInSingleUnitMode. +func (e *SyntaxError) Cause() SyntaxErrorCause { + return e.cause } -func (p *parser) consumeNumber(input string) (int64, error) { - start := p.current - for p.current < len(input) && unicode.IsDigit(rune(input[p.current])) { - p.current++ +// Is checks whether the provided target error matches the +// OverflowError type. This method facilitates the use of the +// errors.Is function for matching against OverflowError. +// +// Example: +// +// if errors.Is(err, &haproxytime.OverflowError{}) { +// // handle OverflowError +// } +func (e *OverflowError) Is(target error) bool { + var overflowError *OverflowError + return errors.As(target, &overflowError) +} + +// Position returns the position in the input string where the +// OverflowError occurred. The position is 0-based, indicating that +// the first character in the input string is at position 0. +func (e *OverflowError) Position() int { + return e.position +} + +// Error returns a formatted message indicating the position and value +// that caused the overflow, and includes additional context from any +// underlying error, if present. +func (e *OverflowError) Error() string { + return fmt.Sprintf("overflow error at position %v: %v exceeds max duration", e.position+1, e.number) +} + +// newOverflowError creates a new OverflowError instance. position +// specifies the 0-indexed position in the input string where the +// overflow error was detected. number is the numeric value in string +// form that caused the overflow. +func newOverflowError(position int, number string) *OverflowError { + return &OverflowError{ + position: position, + number: number, } - if start == p.current { - // This yields a better error message compared to what - // strconv.ParseInt returns for the empty string. - return 0, errors.New("invalid number") +} + +// newSyntaxErrorInvalidNumber creates a new SyntaxError instance with +// the InvalidNumber cause. position specifies the 0-indexed position +// in the input string where the invalid number was detected. +func newSyntaxErrorInvalidNumber(position int) *SyntaxError { + return &SyntaxError{ + cause: InvalidNumber, + position: position, } - return strconv.ParseInt(input[start:p.current], 10, 64) } -func (p *parser) consumeUnit(input string) string { - start := p.current - for p.current < len(input) && unicode.IsLetter(rune(input[p.current])) { - p.current++ +// newSyntaxErrorInvalidUnit creates a new SyntaxError instance with +// the InvalidUnit cause. position specifies the 0-indexed position in +// the input string where the invalid unit was detected. +func newSyntaxErrorInvalidUnit(position int) *SyntaxError { + return &SyntaxError{ + cause: InvalidUnit, + position: position, } - return input[start:p.current] } -func (p *parser) skipWhitespace(input string) { - for p.current < len(input) && unicode.IsSpace(rune(input[p.current])) { - p.current++ +// newSyntaxErrorInvalidUnitOrder creates a new SyntaxError instance +// with the InvalidUnitOrder cause. position specifies the 0-indexed +// position in the input string where the invalid unit order was +// detected. +func newSyntaxErrorInvalidUnitOrder(position int) *SyntaxError { + return &SyntaxError{ + cause: InvalidUnitOrder, + position: position, } } -// ParseDuration translates a string representing a time duration into -// a time.Duration type. The input string can comprise duration values -// with units of days ("d"), hours ("h"), minutes ("m"), seconds -// ("s"), milliseconds ("ms"), and microseconds ("us"). If no unit is -// provided, the default is milliseconds ("ms"). If the input string -// comprises multiple duration values, they are summed to calculate -// the total duration. For example, "1h30m" is interpreted as 1 hour + -// 30 minutes. -// -// Returns: +// newSyntaxErrorExtraneousCharactersInSingleUnitMode creates a new +// SyntaxError instance with the ExtraneousCharactersInSingleUnitMode +// cause. position specifies the 0-indexed position in the input +// string where the extraneous characters were detected. +func newSyntaxErrorExtraneousCharactersInSingleUnitMode(position int) *SyntaxError { + return &SyntaxError{ + cause: ExtraneousCharactersInSingleUnitMode, + position: position, + } +} + +// ParseDuration translates an input string representing a time +// duration into a time.Duration type. The string may include values +// with the following units: // -// - A time.Duration value representing the total duration found in -// the string. +// "d" for days, "h" for hours, "m" for minutes, "s" for seconds, +// "ms" for milliseconds, and "us" for microseconds. // -// - An integer value indicating the position in the input string -// where parsing failed. +// Input examples: +// - "10s" -> 10 seconds +// - "1h30m" -> 1 hour + 30 minutes +// - "500ms" -> 500 milliseconds +// - "100us" -> 100 microseconds +// - "1d5m200" -> 1 day + 5 minutes + 200 milliseconds // -// - An error value representing any parsing error that occurred. +// An empty input results in a zero duration. // // Errors: // -// - It returns an "invalid number" error when a non-numeric or -// improperly formatted numeric value is found. -// -// - It returns an "invalid unit" error when an unrecognised or -// invalid time unit is provided. -// -// - It returns an "invalid unit order" error when the time units in -// the input string are not in descending order from day to -// microsecond or when the same unit is specified more than once. -// -// - It returns an "overflow" error when the total duration value -// exceeds the maximum possible value that a time.Duration can -// represent. +// - SyntaxError: When the input has non-numeric values, +// unrecognised units, improperly formatted values, or units that +// are not in descending order from day to microsecond. // -// - It returns an "underflow" error if any individual time value in -// the input cannot be represented by time.Duration. For example, -// a duration of "9223372036s 1000ms" would return an underflow -// error. +// - OverflowError: If the total duration exceeds HAProxy's maximum +// limit or any individual value in the input leads to an overflow +// in the total duration. // -// The function extracts duration values and their corresponding units -// from the input string and calculates the total duration. It -// tolerates missing units as long as the subsequent units are -// presented in descending order of "d", "h", "m", "s", "ms", and -// "us". A duration value provided without a unit is treated as -// milliseconds by default. -// -// Some examples of valid input strings are: "10s", "1h 30m", "500ms", -// "100us", "1d 5m 200"; in the last example 200 will default to -// milliseconds. Spaces are also optional. -// -// If an empty string is given as input, the function returns zero for -// the duration and no error. -func ParseDuration(input string) (time.Duration, int, error) { - p := parser{} +// Returns a time.Duration of the parsed input or an error if the +// input was invalid. +func ParseDuration(input string, defaultUnit Unit, parseMode ParseMode) (time.Duration, error) { + position := 0 // in input - if err := p.parse(input); err != nil { - return 0, p.position, err - } + var newTotal time.Duration + var prevUnit Unit - checkedAddDurations := func(x, y time.Duration) (time.Duration, error) { - result := x + y - if x > 0 && y > 0 && result < 0 { - return 0, errParseDurationOverflow + for position < len(input) { + numberPosition := position + numberStr, newPos := consumeNumber(input, position) + position = newPos + if numberStr == "" { + return 0, newSyntaxErrorInvalidNumber(numberPosition) + } + + value, err := strconv.ParseInt(numberStr, 10, 32) + if err != nil { + return 0, newOverflowError(numberPosition, numberStr) + } + + unitPosition := position + var unitStr string + if position < len(input) { + unitStr, newPos = consumeUnit(input, position) + position = newPos + } else { + unitStr = unitProperties[defaultUnit].symbol + } + + unit, ok := symbolToUnit[unitStr] + if !ok { + return 0, newSyntaxErrorInvalidUnit(unitPosition) + } + + if prevUnit != 0 && unit <= prevUnit { + return 0, newSyntaxErrorInvalidUnitOrder(unitPosition) + } + + segmentDuration := time.Duration(value) * unitProperties[unit].duration + if newTotal > MaxTimeout-segmentDuration { + return 0, newOverflowError(numberPosition, numberStr) } - return result, nil - } - var err error - var total time.Duration + newTotal += segmentDuration + prevUnit = unit - for i := range p.tokens { - if total, err = checkedAddDurations(total, p.tokens[i].duration); err != nil { - return 0, 0, err + if parseMode == ParseModeSingleUnit && position < len(input) { + return 0, newSyntaxErrorExtraneousCharactersInSingleUnitMode(position) } } - return total, 0, nil + return newTotal, nil } diff --git a/duration_parser_test.go b/duration_parser_test.go index 361e434..dda8b3f 100644 --- a/duration_parser_test.go +++ b/duration_parser_test.go @@ -1,182 +1,421 @@ package haproxytime_test import ( + "errors" + "fmt" + "math" "testing" "time" "github.com/frobware/haproxytime" ) -func TestParseDuration(t *testing.T) { - testCases := []struct { +// TestSyntaxError_Error ensures that the error message produced by a +// SyntaxError provides accurate and clear information about the +// syntax issue encountered. The test crafts a duration string known +// to cause a syntax error and then verifies that the SyntaxError +// generated details the correct position and nature of the error. +func TestSyntaxError_Error(t *testing.T) { + tests := []struct { + input string + expectedPosition int + expectedCause haproxytime.SyntaxErrorCause + expectedErrorMsg string + parseMode haproxytime.ParseMode + }{{ + input: "1h1x", + expectedPosition: 4, + expectedCause: haproxytime.InvalidUnit, + expectedErrorMsg: "syntax error at position 4: invalid unit", + parseMode: haproxytime.ParseModeMultiUnit, + }, { + input: "xx1h", + expectedPosition: 1, + expectedCause: haproxytime.InvalidNumber, + expectedErrorMsg: "syntax error at position 1: invalid number", + parseMode: haproxytime.ParseModeMultiUnit, + }, { + input: "1m1h", + expectedPosition: 4, + expectedCause: haproxytime.InvalidUnitOrder, + expectedErrorMsg: "syntax error at position 4: invalid unit order", + parseMode: haproxytime.ParseModeMultiUnit, + }, { + input: "1h1m1h", + expectedPosition: 3, + expectedCause: haproxytime.ExtraneousCharactersInSingleUnitMode, + expectedErrorMsg: "syntax error at position 3: extraneous characters in single unit mode", + parseMode: haproxytime.ParseModeSingleUnit, + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + _, err := haproxytime.ParseDuration(tc.input, haproxytime.UnitMillisecond, tc.parseMode) + var syntaxErr *haproxytime.SyntaxError + + if !(errors.As(err, &syntaxErr) && errors.Is(err, &haproxytime.SyntaxError{})) { + t.Errorf("Expected a SyntaxError, but got %T", err) + return + } + + if syntaxErr.Error() != tc.expectedErrorMsg { + t.Errorf("expected %q, but got %q", tc.expectedErrorMsg, syntaxErr.Error()) + } + + if syntaxErr.Position() != tc.expectedPosition-1 { + t.Errorf("expected SyntaxError at position %v, but got %v", tc.expectedPosition-1, syntaxErr.Position()) + } + + if syntaxErr.Cause() != tc.expectedCause { + t.Errorf("expected SyntaxError cause to be %v, but got %v", tc.expectedCause, syntaxErr.Cause()) + } + }) + } +} + +// TestOverflowError_Error validates that the error message produced +// by an OverflowError accurately represents the cause of the +// overflow. The test crafts a duration string known to cause an +// overflow, then ensures that the OverflowError generated reports the +// correct position and value causing the overflow. +func TestOverflowError_Error(t *testing.T) { + tests := []struct { description string input string - duration time.Duration - error string + expected string }{{ - description: "test with empty string", - input: "", - duration: 0, + description: "overflows haproxy max duration", + input: "2147483648ms", + expected: "overflow error at position 1: 2147483648 exceeds max duration", }, { - description: "test with string that is just spaces", - input: " ", - duration: 0, + description: "100 days overflows haproxy max duration", + input: "100d", + expected: "overflow error at position 1: 100 exceeds max duration", }, { - description: "test for zero", - input: "0", - duration: 0, + description: "genuine int64 range error", + input: "10000000000000000000", + expected: "overflow error at position 1: 10000000000000000000 exceeds max duration", + }} + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + var overflowErr *haproxytime.OverflowError + + _, err := haproxytime.ParseDuration(tc.input, haproxytime.UnitMillisecond, haproxytime.ParseModeMultiUnit) + ok := errors.As(err, &overflowErr) + if !ok { + t.Fatalf("expected %T, got %T", overflowErr, err) + } + + if overflowErr.Error() != tc.expected { + t.Errorf("expected %q, but got %q", tc.expected, overflowErr.Error()) + } + }) + } +} + +// TestParseDurationOverflowErrors verifies the proper handling of +// overflow errors when parsing duration strings. It ensures that +// values within the acceptable range do not produce errors, while +// values exceeding the limits are correctly identified and reported +// with detailed information, including the problematic number and its +// position within the input. +func TestParseDurationOverflowErrors(t *testing.T) { + tests := []struct { + description string + input string + expectErr bool + expectedErrPos int + duration time.Duration + }{{ + description: "maximum value without overflow (just under the limit)", + input: "2147483647ms", + expectErr: false, + duration: haproxytime.MaxTimeout, }, { - description: "invalid number", - input: "a", - error: "invalid number", + description: "maximum value without overflow (using different time units)", + input: "2147483s647ms", + expectErr: false, + duration: 2147483*time.Second + 647*time.Millisecond, }, { - description: "invalid number", - input: "/", - error: "invalid number", + description: "maximum value without overflow (using different time units)", + input: "35791m23s647ms", + expectErr: false, + duration: 35791*time.Minute + 23*time.Second + 647*time.Millisecond, }, { - description: "invalid number, because the 100 defaults to 100ms", - input: "100.d", - error: "invalid number", + description: "way below the limit", + input: "1000ms", + expectErr: false, + duration: 1000 * time.Millisecond, }, { - description: "invalid unit", - input: "1d 30mgarbage", - error: "invalid unit", + description: "way below the limit", + input: "1s", + expectErr: false, + duration: 1 * time.Second, }, { - description: "valid test with spaces", - input: "1d 3h 30m 45s 100ms 200us", - duration: 27*time.Hour + 30*time.Minute + 45*time.Second + 100*time.Millisecond + 200*time.Microsecond, + description: "MaxInt32 milliseconds", + input: fmt.Sprintf("%dms", math.MaxInt32), + expectErr: false, + duration: time.Duration(math.MaxInt32) * time.Millisecond, + }, { + description: "just over the limit", + input: "2147483648ms", + expectErr: true, + expectedErrPos: 1, + duration: 0, + }, { + description: "over the limit with combined units", + input: "2147483s648ms", + expectErr: true, + expectedErrPos: 9, + duration: 0, + }, { + description: "over the limit (using different time units)", + input: "35791m23s648ms", + expectErr: true, + expectedErrPos: 10, + duration: 0, + }, { + description: "way over the limit", + input: "4294967295ms", + expectErr: true, + expectedErrPos: 1, + duration: 0, + }, { + description: "way, way over the limit", + input: "9223372036855ms", + duration: 0, + expectErr: true, + expectedErrPos: 1, }, { - description: "valid test with no space", + description: "maximum value +1 (using different units)", + input: "24d20h31m23s648ms0us", + expectErr: true, + expectedErrPos: 13, + duration: 0, + }, { + description: "MaxInt32+1 milliseconds", + input: fmt.Sprintf("%vms", math.MaxInt32+1), + expectErr: true, + expectedErrPos: 1, + duration: 0, + }, { + description: "MaxInt64 milliseconds", + input: fmt.Sprintf("%vms", math.MaxInt64), + expectErr: true, + expectedErrPos: 1, + duration: 0, + }} + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + duration, err := haproxytime.ParseDuration(tc.input, haproxytime.UnitMillisecond, haproxytime.ParseModeMultiUnit) + + var overflowErr *haproxytime.OverflowError + + if tc.expectErr { + if !(errors.As(err, &overflowErr) && errors.Is(err, &haproxytime.OverflowError{})) { + t.Errorf("Expected an OverflowError, but got %T", err) + return + } + + // Use a 1-based index in the test + // case to avoid relying on the zero + // value. Adjust by subtracting 1 when + // comparing positions. + if overflowErr.Position() != tc.expectedErrPos-1 { + t.Errorf("Expected OverflowError at position %v, but got %v", tc.expectedErrPos-1, overflowErr.Position()) + } + } else { + if err != nil { + t.Errorf("Didn't expect error for input %q but got %v", tc.input, err) + return + } + if duration != tc.duration { + t.Errorf("expected duration %v, but got %v", tc.duration, duration) + } + } + }) + } +} + +// TestParseDurationSyntaxErrors verifies that duration strings are +// parsed correctly according to their syntax. It checks various valid +// and invalid inputs to ensure the parser handles syntax errors +// appropriately, identifying and reporting any inconsistencies or +// unsupported formats with a detailed error message and the position +// of the problematic segment. +func TestParseDurationSyntaxErrors(t *testing.T) { + tests := []struct { + description string + input string + expectErr bool + expectedPosition int + expectedCause haproxytime.SyntaxErrorCause + duration time.Duration + }{{ + description: "empty string", + input: "", + expectErr: false, + duration: 0, + }, { + description: "zero milliseconds", + input: "0", + expectErr: false, + duration: 0, + }, { + description: "all units specified", input: "1d3h30m45s100ms200us", + expectErr: false, duration: 27*time.Hour + 30*time.Minute + 45*time.Second + 100*time.Millisecond + 200*time.Microsecond, }, { - description: "test with leading and trailing spaces", - input: " 1d 3h 30m 45s ", - duration: 27*time.Hour + 30*time.Minute + 45*time.Second, - }, { - description: "test with no unit (assume milliseconds)", + description: "default unit", input: "5000", + expectErr: false, duration: 5000 * time.Millisecond, }, { - description: "test with no unit (assume milliseconds), followed by another millisecond value", - input: "5000 100ms", - error: "invalid unit order", + description: "number with leading zeros", + input: "0101us", + expectErr: false, + duration: 101 * time.Microsecond, }, { - description: "test number with leading zeros", - input: "000000000000000000000001 01us", - duration: time.Millisecond + time.Microsecond, - }, { - description: "test for zero milliseconds", + description: "zero milliseconds", input: "0ms", + expectErr: false, duration: 0, }, { - description: "test all units as zero", - input: "0d 0h 0m 0s 0ms 0us", + description: "all units as zero", + input: "0d0h0m0s0ms0us", + expectErr: false, duration: 0, }, { - description: "test all units as zero with implicit milliseconds", - input: "0d 0h 0m 0s 0 0us", + description: "all units as zero with implicit milliseconds", + input: "0d0h0m0s00us", + expectErr: false, duration: 0, }, { - description: "test with all zeros, and trailing 0 with no unit but ms has already been specified", - input: "0d 0h 0m 0s 0ms 0", - error: "invalid unit order", - }, { - description: "test 1 millisecond", - input: "0d 0h 0m 0s 1", + description: "1 millisecond", + input: "0d0h0m0s1", + expectErr: false, duration: time.Millisecond, }, { - description: "test duplicate units", - input: "0ms 0ms", - error: "invalid unit order", + description: "skipped units", + input: "1d100us", + expectErr: false, + duration: 24*time.Hour + 100*time.Microsecond, + }, { + description: "maximum number of ms", + input: "2147483647", + expectErr: false, + duration: 2147483647 * time.Millisecond, }, { - description: "test out of order units, hours cannot follow minutes", - input: "1d 5m 1h", - error: "invalid unit order", + description: "maximum number expressed with all units", + input: "24d20h31m23s647ms0us", + expectErr: false, + duration: 2147483647 * time.Millisecond, }, { - description: "test skipped units", - input: "1d 100us", - duration: 24*time.Hour + 100*time.Microsecond, + description: "leading space is not a number", + input: " ", + expectErr: true, + expectedPosition: 1, + expectedCause: haproxytime.InvalidNumber, + duration: 0, + }, { + description: "leading +", + input: "+0", + expectErr: true, + expectedPosition: 1, + expectedCause: haproxytime.InvalidNumber, + duration: 0, + }, { + description: "negative number", + input: "-1", + expectErr: true, + expectedPosition: 1, + expectedCause: haproxytime.InvalidNumber, + duration: 0, + }, { + description: "abc is an invalid number", + input: "abc", + expectErr: true, + expectedPosition: 1, + expectedCause: haproxytime.InvalidNumber, + duration: 0, + }, { + description: "/ is an invalid number", + input: "/", + expectErr: true, + expectedPosition: 1, + expectedCause: haproxytime.InvalidNumber, + duration: 0, + }, { + description: ". is an invalid unit", + input: "100.d", + expectErr: true, + expectedPosition: 4, + expectedCause: haproxytime.InvalidUnit, + duration: 0, + }, { + description: "X is an invalid number after the valid 1d30m", + input: "1d30mX", + expectErr: true, + expectedPosition: 6, + expectedCause: haproxytime.InvalidNumber, + duration: 0, }, { - description: "test maximum number of seconds", - input: "9223372036s", - duration: 9223372036 * time.Second, + description: "Y is an invalid unit after the valid 2d30m and the next digit", + input: "2d30m1Y", + expectErr: true, + expectedPosition: 7, + expectedCause: haproxytime.InvalidUnit, + duration: 0, }, { - description: "test overflow", - input: "9223372036s 1000ms", - error: "overflow", + description: "duplicate units", + input: "0ms0ms", + expectErr: true, + expectedPosition: 5, + expectedCause: haproxytime.InvalidUnitOrder, + duration: 0, }, { - description: "test underflow", - input: "9223372037s", - error: "underflow", + description: "out of order units, hours cannot follow minutes", + input: "1d5m1h", + expectErr: true, + expectedPosition: 6, + expectedCause: haproxytime.InvalidUnitOrder, + duration: 0, }} - for _, tc := range testCases { + for _, tc := range tests { t.Run(tc.description, func(t *testing.T) { - got, _, err := haproxytime.ParseDuration(tc.input) - if err != nil && err.Error() != tc.error { - t.Errorf("%q: wanted error %q, got %q", tc.input, tc.error, err) - return - } - if got != tc.duration { - t.Errorf("%q: wanted value %q, got %q", tc.input, tc.duration, got) - } - }) - } -} + duration, err := haproxytime.ParseDuration(tc.input, haproxytime.UnitMillisecond, haproxytime.ParseModeMultiUnit) -func FuzzParseDuration(f *testing.F) { - f.Add("") - f.Add("0") - f.Add("0d") - f.Add("0ms") - f.Add("1000garbage") - f.Add("100us") - f.Add("10s") - f.Add("1d 3h") - f.Add("1d") - f.Add("1d3h30m45s") - f.Add("1h30m") - f.Add("5000") - f.Add("500ms") - f.Add("9223372036s") - - // Values extracted from the unit tests - testCases := []string{ - "", - "0", - "a", - "/", - "100.d", - "1d 30mgarbage", - "1d 3h 30m 45s 100ms 200us", - "1d3h30m45s100ms200us", - " 1d 3h 30m 45s ", - "5000", - "5000 100ms", - "5000 100ms", - "000000000000000000000001 01us", - "0ms", - "0d 0h 0m 0s 0ms 0us", - "0d 0h 0m 0s 0 0us", - "0d 0h 0m 0s 0ms 0", - "0d 0h 0m 0s 1", - "0ms 0ms", - "1d 5m 1h", - "1d 100us", - "9223372036s", - "9223372036s 1000ms", - "9223372037s", - } + var syntaxErr *haproxytime.SyntaxError - for _, tc := range testCases { - f.Add(tc) + if tc.expectErr { + if !(errors.As(err, &syntaxErr) && errors.Is(err, &haproxytime.SyntaxError{})) { + t.Errorf("Expected a SyntaxError, but got %T", err) + return + } + // Use a 1-based index in the test + // case to avoid relying on the zero + // value. Adjust by subtracting 1 when + // comparing positions. + if syntaxErr.Position() != tc.expectedPosition-1 { + t.Errorf("Expected position %v, but got %v", tc.expectedPosition-1, syntaxErr.Position()) + } + if syntaxErr.Cause() != tc.expectedCause { + t.Errorf("Expected cause %v, but got %v", tc.expectedCause, syntaxErr.Cause()) + } + } else { + if err != nil { + t.Errorf("Didn't expect error for input %q but got %v", tc.input, err) + return + } + if duration != tc.duration { + t.Errorf("expected duration %v, but got %v", tc.duration, duration) + } + } + }) } - - f.Fuzz(func(t *testing.T, input string) { - _, _, err := haproxytime.ParseDuration(input) - if err != nil { - t.Skip() - } - }) } diff --git a/go.mod b/go.mod index 0847208..52b3a2c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/frobware/haproxytime -go 1.18 +go 1.20