diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c28c56d..3af1836 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,18 +39,18 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common ca-certificates gnupg-agent curl build-essential make - # Ref: https://github.com/actions/setup-go - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ">= 1.23" - # Ref: https://github.com/actions/checkout - name: Checkout Source uses: actions/checkout@v4 with: fetch-depth: 0 + # Ref: https://github.com/actions/setup-go + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # Ref: https://github.com/golangci/golangci-lint-action - name: Lint uses: golangci/golangci-lint-action@v6 diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml new file mode 100644 index 0000000..52988cf --- /dev/null +++ b/.github/workflows/codeql-analysis.yaml @@ -0,0 +1,81 @@ +# Ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions +name: "CodeQL Analysis" + +permissions: + contents: read + +on: + workflow_dispatch: + + push: + branches: + - main + + paths-ignore: + - 'CHANGELOG/**' + - 'CODEOWNERS' + - 'docs/**' + - 'LICENSE' + - '**/*.md' + + schedule: + # * * * * * + # | | | | | + # | | | | day of the week (0–6) (Sunday to Saturday; + # | | | month (1–12) 7 is also Sunday on some systems) + # | | day of the month (1–31) + # | hour (0–23) + # minute (0–59) + - cron: '0 0 * * 3' + +jobs: + analyze: + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - language: go + + permissions: + security-events: write + + steps: + - name: Preamble + run: | + whoami + echo github ref $GITHUB_REF + echo workflow $GITHUB_WORKFLOW + echo home $HOME + echo event name $GITHUB_EVENT_NAME + echo workspace $GITHUB_WORKSPACE + + df -h + + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common ca-certificates gnupg-agent curl build-essential make + + # Ref: https://github.com/actions/checkout + - name: Checkout Source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Ref: https://github.com/actions/setup-go + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # Ref: https://github.com/github/codeql-action + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7188db6..080ab7c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -32,18 +32,18 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common ca-certificates gnupg-agent curl build-essential make - # Ref: https://github.com/actions/setup-go - - name: "Install Go" - uses: actions/setup-go@v5 - with: - go-version: ">= 1.23" - # Ref: https://github.com/actions/checkout - name: "Checkout Source" uses: actions/checkout@v4 with: fetch-depth: 0 + # Ref: https://github.com/actions/setup-go + - name: "Install Go" + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: "Compute Release Flags" if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: echo "flags=--snapshot" >> $GITHUB_ENV diff --git a/CHANGELOG/CHANGELOG-1.x.md b/CHANGELOG/CHANGELOG-1.x.md index 6abaf7b..0dc460c 100644 --- a/CHANGELOG/CHANGELOG-1.x.md +++ b/CHANGELOG/CHANGELOG-1.x.md @@ -15,6 +15,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### Security +--- +## [1.18.0] - 2024-NOV-15 + +### Added +- **FEATURE**: Added support for `fmt.Stringer`: Provides a string representation of the ID type. +- **FEATURE**: Added support for `encoding.TextMarshaler`: Supports marshaling ID into a text-based representation. +- **FEATURE**: Added support for `encoding.TextUnmarshaler`: Supports unmarshaling ID from a text-based representation. +- **FEATURE**: Added support for `encoding.BinaryMarshaler`: Supports marshaling ID into a binary representation. +- **FEATURE**: Added support for `encoding.BinaryUnmarshaler`: Supports unmarshaling ID from a binary representation. +- **FEATURE**: Added `DefaultRandReader` to provide a default random reader for generating IDs. +- **FEATURE**: Added `EmptyID` to provide an empty ID constant. +- **RISK**: Added support for CodeQL analysis when pushing to the `main` branch. + +### Changed +### Deprecated +### Removed +### Fixed +- **DEFECT:** Addressed various documentation issues. + +### Security + --- ## [1.17.3] - 2024-NOV-14 @@ -409,7 +430,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### Security -[Unreleased]: https://github.com/sixafter/nanoid/compare/v1.17.3..HEAD +[Unreleased]: https://github.com/sixafter/nanoid/compare/v1.18.0..HEAD +[1.18.0]: https://github.com/sixafter/nanoid/compare/v1.17.3...v1.18.0 [1.17.3]: https://github.com/sixafter/nanoid/compare/v1.17.2...v1.17.3 [1.17.2]: https://github.com/sixafter/nanoid/compare/v1.17.1...v1.17.2 [1.17.1]: https://github.com/sixafter/nanoid/compare/v1.17.0...v1.17.1 diff --git a/Makefile b/Makefile index 9a3900f..9bc1efa 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ bench: ## Execute benchmark tests .PHONY: clean clean: ## Remove previous build - $(GO_CLEAN) + $(GO_CLEAN) ./... .PHONY: cover cover: ## Generate global code coverage report diff --git a/nanoid.go b/nanoid.go index 199bb63..5200d28 100644 --- a/nanoid.go +++ b/nanoid.go @@ -20,8 +20,82 @@ import ( "github.com/sixafter/nanoid/x/crypto/prng" ) -// DefaultGenerator is a global, shared instance of a Nano ID generator. It is safe for concurrent use. -var DefaultGenerator Generator +var ( + // DefaultGenerator is a global, shared instance of a Nano ID generator. It is safe for concurrent use. + DefaultGenerator Generator + + // DefaultRandReader is the default random number generator used for generating IDs. + DefaultRandReader = prng.Reader + + // ErrDuplicateCharacters is returned when the provided alphabet contains duplicate characters. + ErrDuplicateCharacters = errors.New("duplicate characters in alphabet") + + // ErrExceededMaxAttempts is returned when the maximum number of attempts to perform + // an operation, such as generating a unique ID, has been exceeded. + ErrExceededMaxAttempts = errors.New("exceeded maximum attempts") + + // ErrInvalidLength is returned when a specified length value for an operation is invalid. + ErrInvalidLength = errors.New("invalid length") + + // ErrInvalidAlphabet is returned when the provided alphabet for generating IDs is invalid. + ErrInvalidAlphabet = errors.New("invalid alphabet") + + // ErrNonUTF8Alphabet is returned when the provided alphabet contains non-UTF-8 characters. + ErrNonUTF8Alphabet = errors.New("alphabet contains invalid UTF-8 characters") + + // ErrAlphabetTooShort is returned when the provided alphabet has fewer than 2 characters. + ErrAlphabetTooShort = errors.New("alphabet length is less than 2") + + // ErrAlphabetTooLong is returned when the provided alphabet exceeds 256 characters. + ErrAlphabetTooLong = errors.New("alphabet length exceeds 256") + + // ErrNilRandReader is returned when the random number generator (rand.Reader) is nil, + // preventing the generation of random values. + ErrNilRandReader = errors.New("nil random reader") +) + +const ( + // DefaultAlphabet defines the standard set of characters used for Nano ID generation. + // It includes uppercase and lowercase English letters, digits, and the characters + // '_' and '-'. This selection aligns with the Nano ID specification, ensuring + // a URL-friendly and easily readable identifier. + // + // Example: "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + DefaultAlphabet = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + // DefaultLength specifies the default number of characters in a generated Nano ID. + // A length of 21 characters provides a high level of uniqueness while maintaining + // brevity, making it suitable for most applications requiring unique identifiers. + DefaultLength = 21 + + // maxAttemptsMultiplier determines the maximum number of attempts the generator + // will make to produce a valid Nano ID before failing. It is calculated as a + // multiplier based on the desired ID length to balance between performance + // and the probability of successful ID generation, especially when using + // non-power-of-two alphabets. + maxAttemptsMultiplier = 10 + + // MinAlphabetLength sets the minimum permissible number of unique characters + // in the alphabet used for Nano ID generation. An alphabet with fewer than + // 2 characters would not provide sufficient variability for generating unique IDs, + // making this a lower bound to ensure meaningful ID generation. + // + // Example: An alphabet like "AB" is acceptable, but "A" is not. + MinAlphabetLength = 2 + + // MaxAlphabetLength defines the maximum allowable number of unique characters + // in the alphabet for Nano ID generation. This upper limit ensures that the + // generator operates within reasonable memory and performance constraints, + // preventing excessively large alphabets that could degrade performance or + // complicate index calculations. + MaxAlphabetLength = 256 +) + +// ID represents a Nano ID as a string. +type ID string + +// EmptyID represents an empty Nano ID. +var EmptyID = ID("") func init() { var err error @@ -35,7 +109,7 @@ func init() { // It is used with the Function Options pattern. type ConfigOptions struct { // RandReader is the source of randomness used for generating IDs. - // By default, it uses crypto/rand.Reader, which provides cryptographically secure random bytes. + // By default, it uses x/crypto/prng/Reader, which provides cryptographically secure random bytes. RandReader io.Reader // Alphabet is the set of characters used to generate the Nano ID. @@ -166,7 +240,7 @@ type Generator interface { // // handle error // } // fmt.Println("Generated ID:", id) - New(length int) (string, error) + New(length int) (ID, error) // Read fills the provided byte slice 'p' with random data, reading up to len(p) bytes. // Returns the number of bytes read and any error encountered during the read operation. @@ -221,7 +295,7 @@ type generator struct { // // handle error // } // fmt.Println("Generated ID:", id) -func New() (string, error) { +func New() (ID, error) { return NewWithLength(DefaultLength) } @@ -238,7 +312,7 @@ func New() (string, error) { // // handle error // } // fmt.Println("Generated ID:", id) -func NewWithLength(length int) (string, error) { +func NewWithLength(length int) (ID, error) { return DefaultGenerator.New(length) } @@ -251,7 +325,7 @@ func NewWithLength(length int) (string, error) { // // id := nanoid.Must() // fmt.Println("Generated ID:", id) -func Must() string { +func Must() ID { return MustWithLength(DefaultLength) } @@ -268,7 +342,7 @@ func Must() string { // // id := nanoid.MustWithLength(30) // fmt.Println("Generated ID:", id) -func MustWithLength(length int) string { +func MustWithLength(length int) ID { id, err := NewWithLength(length) if err != nil { panic(err) @@ -312,54 +386,6 @@ func Read(p []byte) (n int, err error) { return DefaultGenerator.Read(p) } -var ( - ErrDuplicateCharacters = errors.New("duplicate characters in alphabet") - ErrExceededMaxAttempts = errors.New("exceeded maximum attempts") - ErrInvalidLength = errors.New("invalid length") - ErrInvalidAlphabet = errors.New("invalid alphabet") - ErrNonUTF8Alphabet = errors.New("alphabet contains invalid UTF-8 characters") - ErrAlphabetTooShort = errors.New("alphabet length is less than 2") - ErrAlphabetTooLong = errors.New("alphabet length exceeds 256") - ErrNilRandReader = errors.New("nil random reader") -) - -const ( - // DefaultAlphabet defines the standard set of characters used for Nano ID generation. - // It includes uppercase and lowercase English letters, digits, and the characters - // '_' and '-'. This selection aligns with the Nano ID specification, ensuring - // a URL-friendly and easily readable identifier. - // - // Example: "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - DefaultAlphabet = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - - // DefaultLength specifies the default number of characters in a generated Nano ID. - // A length of 21 characters provides a high level of uniqueness while maintaining - // brevity, making it suitable for most applications requiring unique identifiers. - DefaultLength = 21 - - // maxAttemptsMultiplier determines the maximum number of attempts the generator - // will make to produce a valid Nano ID before failing. It is calculated as a - // multiplier based on the desired ID length to balance between performance - // and the probability of successful ID generation, especially when using - // non-power-of-two alphabets. - maxAttemptsMultiplier = 10 - - // MinAlphabetLength sets the minimum permissible number of unique characters - // in the alphabet used for Nano ID generation. An alphabet with fewer than - // 2 characters would not provide sufficient variability for generating unique IDs, - // making this a lower bound to ensure meaningful ID generation. - // - // Example: An alphabet like "AB" is acceptable, but "A" is not. - MinAlphabetLength = 2 - - // MaxAlphabetLength defines the maximum allowable number of unique characters - // in the alphabet for Nano ID generation. This upper limit ensures that the - // generator operates within reasonable memory and performance constraints, - // preventing excessively large alphabets that could degrade performance or - // complicate index calculations. - MaxAlphabetLength = 256 -) - // Option defines a function type for configuring the Generator. // It allows for flexible and extensible configuration by applying // various settings to the ConfigOptions during Generator initialization. @@ -455,7 +481,7 @@ func NewGenerator(options ...Option) (Generator, error) { // and the default length hint for ID generation. configOpts := &ConfigOptions{ Alphabet: DefaultAlphabet, - RandReader: prng.Reader, + RandReader: DefaultRandReader, LengthHint: DefaultLength, } @@ -698,9 +724,9 @@ func (g *generator) processRandomBytes(randomBytes []byte, i int) uint { // // handle error // } // fmt.Println("Generated ID:", id) -func (g *generator) New(length int) (string, error) { +func (g *generator) New(length int) (ID, error) { if length <= 0 { - return "", ErrInvalidLength + return EmptyID, ErrInvalidLength } if g.config.isASCII { @@ -710,7 +736,7 @@ func (g *generator) New(length int) (string, error) { } // newASCII generates a new Nano ID using the ASCII alphabet. -func (g *generator) newASCII(length int) (string, error) { +func (g *generator) newASCII(length int) (ID, error) { randomBytesPtr := g.entropyPool.Get().(*[]byte) randomBytes := *randomBytesPtr bufferLen := len(randomBytes) @@ -738,7 +764,7 @@ func (g *generator) newASCII(length int) (string, error) { // Fill the random bytes buffer if _, err := g.config.randReader.Read(randomBytes[:neededBytes]); err != nil { - return "", err + return EmptyID, err } // Process each segment of random bytes @@ -755,14 +781,14 @@ func (g *generator) newASCII(length int) (string, error) { // Check for max attempts if cursor < length { - return "", ErrExceededMaxAttempts + return EmptyID, ErrExceededMaxAttempts } - return sb.String(), nil + return ID(sb.String()), nil } // newUnicode generates a new Nano ID using the Unicode alphabet. -func (g *generator) newUnicode(length int) (string, error) { +func (g *generator) newUnicode(length int) (ID, error) { // Retrieve random bytes from the pool randomBytesPtr := g.entropyPool.Get().(*[]byte) randomBytes := *randomBytesPtr @@ -792,7 +818,7 @@ func (g *generator) newUnicode(length int) (string, error) { // Fill the random bytes buffer if _, err := g.config.randReader.Read(randomBytes[:neededBytes]); err != nil { - return "", err + return EmptyID, err } // Process each segment of random bytes @@ -809,10 +835,10 @@ func (g *generator) newUnicode(length int) (string, error) { // Check for max attempts if cursor < length { - return "", ErrExceededMaxAttempts + return EmptyID, ErrExceededMaxAttempts } - return sb.String(), nil + return ID(sb.String()), nil } // Reader is the interface that wraps the basic Read method. @@ -861,6 +887,128 @@ func (g *generator) Read(p []byte) (n int, err error) { return length, nil } +// IsEmpty returns true if the ID is an empty ID (EmptyID) +func (id ID) IsEmpty() bool { + return id.Compare(EmptyID) == 0 +} + +// Compare compares two IDs lexicographically and returns an integer. +// The result will be 0 if id==other, -1 if id < other, and +1 if id > other. +// +// Parameters: +// - other ID: The ID to compare against. +// +// Returns: +// - int: An integer indicating the comparison result. +// +// Usage: +// +// id1 := ID("V1StGXR8_Z5jdHi6B-myT") +// id2 := ID("V1StGXR8_Z5jdHi6B-myT") +// result := id1.Compare(id2) +// fmt.Println(result) // Output: 0 +func (id ID) Compare(other ID) int { + return strings.Compare(string(id), string(other)) +} + +// String returns the string representation of the ID. +// It implements the fmt.Stringer interface, allowing the ID to be +// used seamlessly with fmt package functions like fmt.Println and fmt.Printf. +// +// Example: +// +// id := Must() +// fmt.Println(id) // Output: V1StGXR8_Z5jdHi6B-myT +func (id ID) String() string { + return string(id) +} + +// MarshalText converts the ID to a byte slice. +// It implements the encoding.TextMarshaler interface, enabling the ID +// to be marshaled into text-based formats such as XML and YAML. +// +// Returns: +// - A byte slice containing the ID. +// - An error if the marshaling fails. +// +// Example: +// +// id := Must() +// text, err := id.MarshalText() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(text)) // Output: V1StGXR8_Z5jdHi6B-myT +func (id ID) MarshalText() ([]byte, error) { + return []byte(id), nil +} + +// UnmarshalText parses a byte slice and assigns the result to the ID. +// It implements the encoding.TextUnmarshaler interface, allowing the ID +// to be unmarshaled from text-based formats. +// +// Parameters: +// - text: A byte slice containing the ID data. +// +// Returns: +// - An error if the unmarshaling fails. +// +// Example: +// +// var id ID +// err := id.UnmarshalText([]byte("new-id")) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(id) // Output: new-id +func (id *ID) UnmarshalText(text []byte) error { + *id = ID(text) + return nil +} + +// MarshalBinary converts the ID to a byte slice. +// It implements the encoding.BinaryMarshaler interface, enabling the ID +// to be marshaled into binary formats for efficient storage or transmission. +// +// Returns: +// - A byte slice containing the ID. +// - An error if the marshaling fails. +// +// Example: +// +// id := Must() +// binaryData, err := id.MarshalBinary() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(binaryData) // Output: [86 49 83 116 71 88 82 56 95 90 ...] +func (id ID) MarshalBinary() ([]byte, error) { + return []byte(id), nil +} + +// UnmarshalBinary parses a byte slice and assigns the result to the ID. +// It implements the encoding.BinaryUnmarshaler interface, allowing the ID +// to be unmarshaled from binary formats. +// +// Parameters: +// - data: A byte slice containing the binary ID data. +// +// Returns: +// - An error if the unmarshaling fails. +// +// Example: +// +// var id ID +// err := id.UnmarshalBinary([]byte{86, 49, 83, 116, 71, 88, 82, 56, 95, 90}) // "V1StGXR8_Z5jdHi6B-myT" +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(id) // Output: V1StGXR8_Z5jdHi6B-myT +func (id *ID) UnmarshalBinary(data []byte) error { + *id = ID(data) + return nil +} + // Config holds the runtime configuration for the Nano ID generator. // // It is immutable after initialization and provides all the necessary diff --git a/nanoid_test.go b/nanoid_test.go index f488ee8..15ee7fa 100644 --- a/nanoid_test.go +++ b/nanoid_test.go @@ -6,7 +6,9 @@ package nanoid import ( + "encoding" "errors" + "fmt" "io" "math/bits" "strconv" @@ -17,6 +19,23 @@ import ( "github.com/stretchr/testify/assert" ) +var ( + // Ensure ID implements the fmt.Stringer interface + _ = fmt.Stringer(EmptyID) + + // Ensure ID implements the encoding.BinaryMarshaler interface + _ = encoding.BinaryMarshaler(EmptyID) + + // Ensure ID implements the encoding.BinaryUnmarshaler interface + _ = encoding.BinaryUnmarshaler(&EmptyID) + + // Ensure ID implements the encoding.TextMarshaler interface + _ = encoding.TextMarshaler(EmptyID) + + // Ensure ID implements the encoding.TextUnmarshaler interface + _ = encoding.TextUnmarshaler(&EmptyID) +) + // TestNewWithCustomLengths tests the generation of Nano IDs with custom lengths. func TestNewWithCustomLengths(t *testing.T) { t.Parallel() @@ -223,7 +242,7 @@ func TestUniqueness(t *testing.T) { is := assert.New(t) numIDs := 1000 - ids := make(map[string]struct{}, numIDs) + ids := make(map[ID]struct{}, numIDs) for i := 0; i < numIDs; i++ { id, err := New() @@ -244,7 +263,7 @@ func TestConcurrency(t *testing.T) { var wg sync.WaitGroup wg.Add(numGoroutines) - ids := make(chan string, numGoroutines*numIDsPerGoroutine) + ids := make(chan ID, numGoroutines*numIDsPerGoroutine) errorsChan := make(chan error, numGoroutines*numIDsPerGoroutine) for i := 0; i < numGoroutines; i++ { @@ -269,7 +288,7 @@ func TestConcurrency(t *testing.T) { is.NoError(err, "New() should not return an error in concurrent execution") } - idSet := make(map[string]struct{}, numGoroutines*numIDsPerGoroutine) + idSet := make(map[ID]struct{}, numGoroutines*numIDsPerGoroutine) for id := range ids { if _, exists := idSet[id]; exists { is.Failf("Duplicate ID found in concurrency test", "Duplicate ID: %s", id) @@ -279,7 +298,7 @@ func TestConcurrency(t *testing.T) { } // isValidID checks if all characters in the ID are within the specified alphabet. -func isValidID(id string, alphabet string) bool { +func isValidID(id ID, alphabet string) bool { alphabetSet := make(map[rune]struct{}, len([]rune(alphabet))) for _, char := range alphabet { alphabetSet[char] = struct{}{} @@ -353,17 +372,17 @@ func TestWithRandReader(t *testing.T) { // New ID of length 4 id, err := gen.New(4) is.NoError(err, "New(4) should not return an error") - is.Equal("ABCD", id, "Generated ID should match the expected sequence 'ABCD'") + is.Equal("ABCD", string(id), "Generated ID should match the expected sequence 'ABCD'") // New another ID of length 4, should cycle through customBytes again id, err = gen.New(4) is.NoError(err, "New(4) should not return an error on subsequent generation") - is.Equal("ABCD", id, "Generated ID should match the expected sequence 'ABCD' on subsequent generation") + is.Equal("ABCD", string(id), "Generated ID should match the expected sequence 'ABCD' on subsequent generation") // New ID of length 8, should cycle through customBytes twice id, err = gen.New(8) is.NoError(err, "New(8) should not return an error") - is.Equal("ABCDABCD", id, "Generated ID should match the expected sequence 'ABCDABCD' for length 8") + is.Equal("ABCDABCD", string(id), "Generated ID should match the expected sequence 'ABCDABCD' for length 8") } // TestWithRandReaderDifferentSequence tests the WithRandReader option with a different byte sequence and alphabet. @@ -389,17 +408,17 @@ func TestWithRandReaderDifferentSequence(t *testing.T) { // New ID of length 4 id, err := gen.New(4) is.NoError(err, "New(4) should not return an error") - is.Equal("ZYXW", id, "Generated ID should match the expected sequence 'ZYXW'") + is.Equal("ZYXW", string(id), "Generated ID should match the expected sequence 'ZYXW'") // New another ID of length 4, should cycle through customBytes again id, err = gen.New(4) is.NoError(err, "New(4) should not return an error on subsequent generation") - is.Equal("ZYXW", id, "Generated ID should match the expected sequence 'ZYXW' on subsequent generation") + is.Equal("ZYXW", string(id), "Generated ID should match the expected sequence 'ZYXW' on subsequent generation") // New ID of length 8, should cycle through customBytes twice id, err = gen.New(8) is.NoError(err, "New(8) should not return an error") - is.Equal("ZYXWZYXW", id, "Generated ID should match the expected sequence 'ZYXWZYXW' for length 8") + is.Equal("ZYXWZYXW", string(id), "Generated ID should match the expected sequence 'ZYXWZYXW' for length 8") } // TestWithRandReaderInsufficientBytes tests the generator's behavior when the custom reader provides insufficient bytes. @@ -425,12 +444,12 @@ func TestWithRandReaderInsufficientBytes(t *testing.T) { // New ID of length 4, expecting 'FFFF' id, err := gen.New(4) is.NoError(err, "New(4) should not return an error") - is.Equal("FFFF", id, "Generated ID should match the expected sequence 'FFFF'") + is.Equal("FFFF", string(id), "Generated ID should match the expected sequence 'FFFF'") // New ID of length 6, expecting 'FFFFFF' id, err = gen.New(6) is.NoError(err, "New(6) should not return an error") - is.Equal("FFFFFF", id, "Generated ID should match the expected sequence 'FFFFFF'") + is.Equal("FFFFFF", string(id), "Generated ID should match the expected sequence 'FFFFFF'") } // TestGenerateWithNonPowerOfTwoAlphabetLength tests ID generation with an alphabet length that is not a power of two. @@ -612,13 +631,13 @@ func TestGeneratorBufferReuse(t *testing.T) { // Generate first ID id1, err := gen.New(idLength) is.NoError(err, "gen.New(%d) should not return an error", idLength) - is.Equal(idLength, len([]rune(id1)), "Generated ID should have the specified length") + is.Equal(idLength, len([]rune(id1.String())), "Generated ID should have the specified length") is.True(isValidID(id1, customAlphabet), "Generated ID contains invalid characters") // Generate second ID id2, err := gen.New(idLength) is.NoError(err, "gen.New(%d) should not return an error", idLength) - is.Equal(idLength, len([]rune(id2)), "Generated ID should have the specified length") + is.Equal(idLength, len([]rune(id2.String())), "Generated ID should have the specified length") is.True(isValidID(id2, customAlphabet), "Generated ID contains invalid characters") // Ensure that IDs are different if possible @@ -739,7 +758,7 @@ func TestGeneratorConcurrencyWithCustomAlphabetLength(t *testing.T) { var wg sync.WaitGroup wg.Add(numGoroutines) - ids := make(chan string, numGoroutines*numIDsPerGoroutine) + ids := make(chan ID, numGoroutines*numIDsPerGoroutine) errorsChan := make(chan error, numGoroutines*numIDsPerGoroutine) for i := 0; i < numGoroutines; i++ { @@ -764,7 +783,7 @@ func TestGeneratorConcurrencyWithCustomAlphabetLength(t *testing.T) { is.NoError(err, "gen.New() should not return an error in concurrent execution") } - idSet := make(map[string]struct{}, numGoroutines*numIDsPerGoroutine) + idSet := make(map[ID]struct{}, numGoroutines*numIDsPerGoroutine) for id := range ids { if _, exists := idSet[id]; exists { is.Failf("Duplicate ID found in concurrency test", "Duplicate ID: %s", id) @@ -934,7 +953,7 @@ func TestGenerator_Read_EqualLength(t *testing.T) { is.NoError(err, "Read should not return an error") is.Equal(DefaultLength, n, "Number of bytes read should equal DefaultLength") - id := string(buffer) + id := ID(buffer) is.Equal(DefaultLength, len(id), "Generated ID length should match DefaultLength") is.True(isValidID(id, DefaultAlphabet), "Generated ID should contain only valid characters") } @@ -953,7 +972,7 @@ func TestGenerator_Read_SmallerBuffer(t *testing.T) { is.NoError(err, "Read should not return an error") is.Equal(length, n, "Number of bytes read should equal bufferSize") - id := string(buffer) + id := ID(buffer) is.Equal(length, len(id), "Generated ID length should match bufferSize") is.True(isValidID(id, DefaultAlphabet), "Generated ID should contain only valid characters") } @@ -972,7 +991,7 @@ func TestGenerator_Read_LargerBuffer(t *testing.T) { is.NoError(err, "Read should not return an error") is.Equal(length, n, "Number of bytes read should equal bufferSize") - id := string(buffer) + id := ID(buffer) is.Equal(length, len(id), "Generated ID length should match bufferSize") is.True(isValidID(id, DefaultAlphabet), "Generated ID should contain only valid characters") } @@ -1004,7 +1023,7 @@ func TestGenerator_Read_Concurrent(t *testing.T) { bufferSize := DefaultLength var wg sync.WaitGroup var mu sync.Mutex - generatedIDs := make(map[string]bool) + generatedIDs := make(map[ID]bool) for i := 0; i < numGoroutines; i++ { wg.Add(1) @@ -1018,7 +1037,7 @@ func TestGenerator_Read_Concurrent(t *testing.T) { } is.Equal(bufferSize, n, "Number of bytes read should equal bufferSize") - id := string(buffer) + id := ID(buffer) is.Equal(bufferSize, len(id), "Generated ID length should match bufferSize") is.True(isValidID(id, DefaultAlphabet), "Generated ID should contain only valid characters") @@ -1058,3 +1077,142 @@ type errorReader struct{} func (e *errorReader) Read(_ []byte) (int, error) { return 0, errors.New("simulated read error") } + +// TestID_String tests the String() method of the ID type. +// It verifies that the String() method returns the underlying string value. +func TestID_String(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Initialize expected using Must() + expectedID := Must() + expected := expectedID.String() + + // Actual is obtained by calling String() on the ID + actual := expectedID.String() + + // Assert that actual equals expected + is.Equal(expected, actual, "ID.String() should return the underlying string") +} + +// TestID_MarshalText tests the MarshalText() method of the ID type. +// It verifies that MarshalText() returns the correct byte slice representation of the ID. +func TestID_MarshalText(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Initialize expected using Must() + expectedID := Must() + expectedBytes := []byte(expectedID.String()) + + // Actual is obtained by calling MarshalText() + actualBytes, err := expectedID.MarshalText() + + // Assert no error occurred + is.NoError(err, "MarshalText() should not return an error") + + // Assert that actual bytes match expected bytes + is.Equal(expectedBytes, actualBytes, "MarshalText() should return the correct byte slice") +} + +// TestID_UnmarshalText tests the UnmarshalText() method of the ID type. +// It verifies that UnmarshalText() correctly parses the byte slice and assigns the value to the ID. +func TestID_UnmarshalText(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Initialize expected using Must() + expectedID := Must() + inputBytes := []byte(expectedID.String()) + + // Initialize a zero-valued ID + var actualID ID + + // Call UnmarshalText with inputBytes + err := actualID.UnmarshalText(inputBytes) + + // Assert no error occurred + is.NoError(err, "UnmarshalText() should not return an error") + + // Assert that actualID matches expectedID + is.Equal(expectedID, actualID, "UnmarshalText() should correctly assign the input value to ID") +} + +// TestID_MarshalBinary tests the MarshalBinary() method of the ID type. +// It verifies that MarshalBinary() returns the correct byte slice representation of the ID. +func TestID_MarshalBinary(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Initialize expected using Must() + expectedID := Must() + expectedBytes := []byte(expectedID.String()) + + // Actual is obtained by calling MarshalBinary() + actualBytes, err := expectedID.MarshalBinary() + + // Assert no error occurred + is.NoError(err, "MarshalBinary() should not return an error") + + // Assert that actual bytes match expected bytes + is.Equal(expectedBytes, actualBytes, "MarshalBinary() should return the correct byte slice") +} + +// TestID_UnmarshalBinary tests the UnmarshalBinary() method of the ID type. +// It verifies that UnmarshalBinary() correctly parses the byte slice and assigns the value to the ID. +func TestID_UnmarshalBinary(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Initialize expected using Must() + expectedID := Must() + inputBytes := []byte(expectedID.String()) + + // Initialize a zero-valued ID + var actualID ID + + // Call UnmarshalBinary with inputBytes + err := actualID.UnmarshalBinary(inputBytes) + + // Assert no error occurred + is.NoError(err, "UnmarshalBinary() should not return an error") + + // Assert that actualID matches expectedID + is.Equal(expectedID, actualID, "UnmarshalBinary() should correctly assign the input value to ID") +} + +// TestID_Compare tests the Compare() method of the ID type. +// It verifies that Compare() correctly compares two IDs and returns the expected result. +func TestID_Compare(t *testing.T) { + t.Parallel() + is := assert.New(t) + + id1 := ID("FgEVN8QMTrnKGvBxFjtjw") + id2 := ID("zTxG5Nl21ZAoM8Fabqk3H") + + // Case 1: id1 < id2 + is.Equal(-1, id1.Compare(id2), "id1 should be less than id2") + + // Case 2: id1 = id2 + is.Equal(0, id1.Compare(id1), "id1 should be equal to id1") + + // Case 3: id1 > id2 + is.Equal(1, id2.Compare(id1), "id2 should be greater than id1") +} + +// TestID_IsEmpty tests the IsEmpty() method of the ID type. +// It verifies that IsEmpty() correctly returns true for an empty ID and false for a non-empty ID. +func TestID_IsEmpty(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Initialize two IDs using Must() + id1 := Must() + id2 := EmptyID + + // Case 1: id1 is not empty + is.False(id1.IsEmpty(), "id1 should not be empty") + + // Case 2: id2 is empty + is.True(id2.IsEmpty(), "id2 should be empty") +}