diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 137740c..4a5e985 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -23,4 +23,7 @@ jobs: run: go vet ./... - name: Build - run: go build -v ./... \ No newline at end of file + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/Makefile b/Makefile index 7406a93..12b0f4a 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +.PHONY: build test clean require-version release \ + release-darwin-arm64 release-linux-amd64 \ + release-linux-arm64 release-windows-amd64 + VERSION ?= dev BINARY_NAME = puff RELEASE_DIR = dist @@ -7,6 +11,11 @@ build: go vet ./... go build -o $(BINARY_NAME) +test: + go vet ./... + go clean -testcache + go test -v ./... + release-darwin-arm64: require-version @echo "Building for darwin-arm64" rm -rf $(RELEASE_DIR) diff --git a/main.go b/main.go index 6b92f24..f35fd4a 100644 --- a/main.go +++ b/main.go @@ -7,9 +7,10 @@ import ( "errors" "fmt" "io" - "log" "os" + "strconv" "strings" + "time" "github.com/google/uuid" "github.com/urfave/cli/v2" @@ -30,6 +31,7 @@ const ( numParam = "num" outputParam = "output" suffixParam = "suffix" + timeParam = "time" urlSafeParam = "url-safe" uuidVersionParam = "version" @@ -40,6 +42,7 @@ const ( var ( errInvalidIterations = errors.New("--num must be greater than 0") errBlankDelimiter = errors.New("--delimiter cannot be blank") + errInvalidUUIDV7Time = errors.New("UUIDv7 does not support pre unix epoch") ) // version holds the application version number. This value is set at build @@ -141,6 +144,40 @@ func generateHex(c *cli.Context) error { return nil } +func generateUUIDV4() (uuid.UUID, error) { + return uuid.NewRandom() +} + +func generateUUIDV7(customTime string) (uuid.UUID, error) { + var err error + var ret uuid.UUID + var timestamp time.Time + + if ret, err = uuid.NewV7(); err != nil { + return uuid.UUID{}, err + } + + if len(customTime) == 0 { + return ret, nil + } + + if timestamp, err = parseTimeInput(customTime); err != nil { + return uuid.UUID{}, err + } + + // replace the first 6-bytes with the custom timestamp + msec := timestamp.UnixMilli() + + ret[0] = byte(msec >> 40) + ret[1] = byte(msec >> 32) + ret[2] = byte(msec >> 24) + ret[3] = byte(msec >> 16) + ret[4] = byte(msec >> 8) + ret[5] = byte(msec) + + return ret, nil +} + // generateUUID generates one or more UUID strings in hexadeicmal. It defaults // to generating version 7 UUIDs but also supports the widely used version 4. func generateUUID(c *cli.Context) error { @@ -164,14 +201,15 @@ func generateUUID(c *cli.Context) error { return paintError(errBlankDelimiter) } - for i := 0; i < iterations; i++ { + for i := range iterations { var err error var id uuid.UUID - if version == uuidV4 { - id, err = uuid.NewRandom() - } else { - id, err = uuid.NewV7() + switch version { + case uuidV4: + id, err = generateUUIDV4() + case uuidV7: + id, err = generateUUIDV7(c.String(timeParam)) } if err != nil { @@ -256,6 +294,45 @@ func generateBinaryBlob(c *cli.Context) error { return nil } +// parseTimeInput attempts to parse the given string time input. On success, +// it will return the time.Time value computed from the input. +func parseTimeInput(input string) (time.Time, error) { + // first, check if the input is a unix timestamp + if parsed, err := strconv.ParseInt(input, 10, 64); err == nil { + // UUIDv7 does not support pre unix epoch + if parsed < 0 { + return time.Time{}, errInvalidUUIDV7Time + } + + if parsed <= 9999999999 { + return time.Unix(parsed, 0), nil + } else { + return time.UnixMilli(parsed), nil + } + } + + // not an integer, try various string formats + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } + + for _, format := range formats { + if t, err := time.Parse(format, input); err == nil { + if t.Before(time.Unix(0, 0)) { + return time.Time{}, errInvalidUUIDV7Time + } + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unsupported time format: %s", input) +} + func main() { delimiterFlag := &cli.StringFlag{ Name: "delimiter", @@ -324,6 +401,11 @@ func main() { Name: "compact", Usage: "print uuid strings without dashes", }, + &cli.StringFlag{ + Name: "time", + Aliases: []string{"t"}, + Usage: "timestamp for UUID v7 (iso8601 or unix timestamp)", + }, delimiterFlag, suffixFlag, }, @@ -376,6 +458,7 @@ func main() { } if err := app.Run(os.Args); err != nil { - log.Fatal(err) + fmt.Println(err) + os.Exit(1) } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..3ddb938 --- /dev/null +++ b/main_test.go @@ -0,0 +1,283 @@ +package main + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestParseTimeInput(t *testing.T) { + tests := []struct { + name string + input string + wantTime time.Time + wantErr bool + }{ + { + name: "with empty string", + input: "", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with invalid string", + input: "not-a-time", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with pre-1970 date string", + input: "1969-12-31", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with pre-1970 rfc3339 format", + input: "1969-12-31T23:59:59Z", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with pre-1970 iso8601 with offset", + input: "1969-12-31T18:59:59-05:00", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with just before unix epoch", + input: "1969-12-31T23:59:59.999Z", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with negative unix timestamp (seconds)", + input: "-1687689000", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with negative unix timestamp (milliseconds)", + input: "-1687689000123", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with unix epoch boundary", + input: "1970-01-01T00:00:00Z", + wantTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with only date", + input: "2023-06-15", + wantTime: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with timezone-less datetime", + input: "2023-06-15 10:30:00", + wantTime: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with seconds-less datetime", + input: "2023-06-15 10:30", + wantTime: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with rfc3339 format", + input: "2025-06-15T10:30:00.123Z", + wantTime: time.Date(2025, 6, 15, 10, 30, 0, 123000000, time.UTC), + wantErr: false, + }, + { + name: "with iso8601 format including timezone", + input: "2025-06-15T10:30:00Z", + wantTime: time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with iso8601 format including offset", + input: "2023-06-15T10:30:00-07:00", + wantTime: time.Date(2023, 6, 15, 17, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with unix timestamp (seconds)", + input: "1687689000", + wantTime: time.Unix(1687689000, 0), + wantErr: false, + }, + { + name: "with unix timestamp (milliseconds)", + input: "1687689000123", + wantTime: time.UnixMilli(1687689000123), + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed, err := parseTimeInput(test.input) + + if test.wantErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if !parsed.Equal(test.wantTime) { + t.Errorf("got: %v, want: %v", parsed, test.wantTime) + } + }) + } +} + +func TestGenerateUUIDV7WithCustomTime(t *testing.T) { + tests := []struct { + name string + input string + wantTime time.Time + wantErr bool + }{ + { + name: "with invalid string", + input: "not-a-time", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with pre-1970 date string", + input: "1969-12-31", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with pre-1970 rfc3339 format", + input: "1969-12-31T23:59:59Z", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with pre-1970 iso8601 with offset", + input: "1969-12-31T18:59:59-05:00", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with just before unix epoch", + input: "1969-12-31T23:59:59.999Z", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with negative unix timestamp (seconds)", + input: "-1687689000", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with negative unix timestamp (milliseconds)", + input: "-1687689000123", + wantTime: time.Time{}, + wantErr: true, + }, + { + name: "with unix epoch boundary", + input: "1970-01-01T00:00:00Z", + wantTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with only date", + input: "2023-06-15", + wantTime: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with timezone-less datetime", + input: "2023-06-15 10:30:00", + wantTime: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with seconds-less datetime", + input: "2023-06-15 10:30", + wantTime: time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with rfc3339 format", + input: "2025-06-15T10:30:00.123Z", + wantTime: time.Date(2025, 6, 15, 10, 30, 0, 123000000, time.UTC), + wantErr: false, + }, + { + name: "with iso8601 format including timezone", + input: "2025-06-15T10:30:00Z", + wantTime: time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with iso8601 format including offset", + input: "2023-06-15T10:30:00-07:00", + wantTime: time.Date(2023, 6, 15, 17, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "with unix timestamp (seconds)", + input: "1687689000", + wantTime: time.Unix(1687689000, 0), + wantErr: false, + }, + { + name: "with unix timestamp (milliseconds)", + input: "1687689000123", + wantTime: time.UnixMilli(1687689000123), + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := generateUUIDV7(test.input) + + if test.wantErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result.Version() != uuidV7 { + t.Fatalf("unexpected uuid version, got: %d", result.Version()) + } + + extractedTime := extractTimeFromUUIDV7(result) + + if !extractedTime.Equal(test.wantTime) { + t.Errorf("Time mismatch: got %v, want %v", extractedTime, test.wantTime) + } + }) + } +} + +func extractTimeFromUUIDV7(u uuid.UUID) time.Time { + msec := int64(u[0])<<40 | int64(u[1])<<32 | int64(u[2])<<24 | + int64(u[3])<<16 | int64(u[4])<<8 | int64(u[5]) + + return time.UnixMilli(msec) +}