diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..fbf2cf9 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,41 @@ +{ + "name": "devcontainer", + "image": "mcr.microsoft.com/devcontainers/go:1.24", + "remoteUser": "vscode", + "initializeCommand": "", + "postCreateCommand": "go mod download", + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + "editor.formatOnSave": true, + "editor.insertSpaces": true, + "files.autoSave": "onFocusChange", + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "go.toolsManagement.autoUpdate": true, + "go.lintTool": "golangci-lint", + "go.formatTool": "goimports", + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "go.mod": "go.sum", + "*.go": "${basename}_test.go" + } + }, + "extensions": [ + "golang.go", + "github.vscode-github-actions" + ] + } + }, + "mounts": [ + "source=go-modules,target=/go,type=volume" + ], + "features": { + "ghcr.io/devcontainers-extra/features/zsh-plugins:0": { + "plugins": "git golang zsh-autosuggestions zsh-syntax-highlighting zsh-you-should-use", + "omzPlugins": "https://github.com/zsh-users/zsh-autosuggestions https://github.com/zsh-users/zsh-syntax-highlighting https://github.com/MichaelAquilina/zsh-you-should-use", + "username": "vscode" + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e830d47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + - name: Build + run: go build ./... + - name: Test + run: go test -v ./... + - name: Lint + run: go vet ./... diff --git a/README.md b/README.md index 220ac04..3062303 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,44 @@ # platekit -`Go library to generate deterministic random license‑plate strings (format XX‑000‑000‑XX) from seeds or input strings.` + +[![CI](https://github.com/Mathious6/platekit/actions/workflows/ci.yml/badge.svg)](https://github.com/Mathious6/platekit/actions) +[![Go Reference](https://pkg.go.dev/badge/github.com/Mathious6/platekit.svg)](https://pkg.go.dev/github.com/Mathious6/platekit) + +Go library to generate deterministic random license‑plate strings (format `XX-000-000-XX`) from seeds or input strings. + +## Installation + +```sh +go get github.com/Mathious6/platekit +``` + +## Usage + +```go +package main + +import ( + "fmt" + "github.com/Mathious6/platekit" +) + +func main() { + fmt.Println("Random plate:", platekit.Generate()) + fmt.Println("Plate from seed (42):", platekit.GenerateFromSeed(42)) + fmt.Println("Plate from string ('hello'):", platekit.GenerateFromString("hello")) +} +``` + +## Features +- Deterministic output from seed or string +- Simple, fast, and dependency-free +- Always returns a string in the format `XX-000-000-XX` + +## Inspiration +This project was inspired by [hugoattal/hashplate](https://github.com/hugoattal/hashplate), a tiny and fast library to generate human-readable hashes in the style of license plates. + +## Used in +- [Mathious6/httpkit](https://github.com/Mathious6/httpkit) + +--- + +Feel free to open issues or PRs for improvements! diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..77578c0 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + + "github.com/Mathious6/platekit" +) + +func main() { + fmt.Println("Random plate:", platekit.Generate()) + fmt.Println("Plate from seed (42):", platekit.GenerateFromSeed(42)) + fmt.Println("Plate from string ('hello'):", platekit.GenerateFromString("hello")) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4eeceac --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Mathious6/platekit + +go 1.24.3 diff --git a/platekit.go b/platekit.go new file mode 100644 index 0000000..60cbb36 --- /dev/null +++ b/platekit.go @@ -0,0 +1,88 @@ +// Package platekit provides functions to generate random license plate strings. +// +// The license plate format is: `XX-000-000-XX`. +package platekit + +import ( + "fmt" + "math" + "math/rand/v2" +) + +const ( + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + alphabetLength = len(alphabet) + + numDigitsInGroup = 3 + numLettersInGroup = 2 + + seedMultiplier = 31 + maxDigit = 10 +) + +// Generate returns a random license plate string. +func Generate() string { + return GenerateFromSeed(rand.Int()) +} + +// GenerateFromSeed returns a license plate string based on a given seed. +func GenerateFromSeed(seed int) string { + return plateFromSeed(seed) +} + +// GenerateFromString returns a license plate string based on a string value. +func GenerateFromString(value string) string { + seed := seedFromString(value) + return plateFromSeed(seed) +} + +// splitMix32 returns a deterministic pseudo-random float64 generator based on the seed. +func splitMix32(seed int) func() float64 { + return func() float64 { + seed += 0x9e3779b9 + hash := seed ^ (seed >> 16) + hash = hash * 0x21f0aaad + hash ^= hash >> 15 + hash *= 0x735a2d97 + hash ^= hash >> 15 + return float64(hash) / float64(math.MaxInt) + } +} + +// seedFromString creates a deterministic seed from a string. +func seedFromString(value string) int { + var seed int + for _, char := range value { + seed = seed*seedMultiplier + int(char) + } + return seed +} + +// plateFromSeed generates a license plate string from a seed. +func plateFromSeed(seed int) string { + random := splitMix32(seed) + return fmt.Sprintf("%s-%s-%s-%s", + generateLetters(random), + generateDigits(random), + generateDigits(random), + generateLetters(random), + ) +} + +// generateLetters returns a string of random uppercase letters of length numLettersInGroup. +func generateLetters(random func() float64) string { + letters := make([]byte, numLettersInGroup) + for i := range numLettersInGroup { + letters[i] = alphabet[int(math.Floor(random()*float64(alphabetLength)))] + } + return string(letters) +} + +// generateDigits returns a string of random digits of length numDigitsInGroup. +func generateDigits(random func() float64) string { + digits := make([]byte, numDigitsInGroup) + for i := range numDigitsInGroup { + digits[i] = byte('0' + int(math.Floor(random()*maxDigit))) + } + return string(digits) +} diff --git a/platekit_test.go b/platekit_test.go new file mode 100644 index 0000000..002a090 --- /dev/null +++ b/platekit_test.go @@ -0,0 +1,115 @@ +package platekit + +import ( + "strings" + "testing" +) + +func TestGenerate_GivenNothing_WhenCalled_ThenReturnsPlateFormat(t *testing.T) { + plate := Generate() + if !isPlateFormat(plate) { + t.Errorf("Expected plate format, got %q", plate) + } +} + +func TestGenerateFromSeed_GivenSeed_WhenCalled_ThenDeterministicPlate(t *testing.T) { + seed := 12345 + plate1 := GenerateFromSeed(seed) + plate2 := GenerateFromSeed(seed) + if plate1 != plate2 { + t.Errorf("Expected deterministic output, got %q and %q", plate1, plate2) + } + if !isPlateFormat(plate1) { + t.Errorf("Expected plate format, got %q", plate1) + } +} + +func TestGenerateFromString_GivenString_WhenCalled_ThenDeterministicPlate(t *testing.T) { + str := "hello world" + plate1 := GenerateFromString(str) + plate2 := GenerateFromString(str) + if plate1 != plate2 { + t.Errorf("Expected deterministic output, got %q and %q", plate1, plate2) + } + if !isPlateFormat(plate1) { + t.Errorf("Expected plate format, got %q", plate1) + } +} + +func TestSeedFromString_GivenString_WhenCalled_ThenReturnsSeed(t *testing.T) { + t.Run("GivenEmptyString_WhenSeedFromString_ThenZero", func(t *testing.T) { + if got := seedFromString(""); got != 0 { + t.Errorf("Expected 0, got %d", got) + } + }) + t.Run("GivenString_WhenSeedFromString_ThenNonZero", func(t *testing.T) { + if got := seedFromString("abc"); got == 0 { + t.Errorf("Expected non-zero, got %d", got) + } + }) +} + +func TestSplitMix32_GivenSeed_WhenCalled_ThenReturnsDeterministicFloats(t *testing.T) { + gen1 := splitMix32(42) + gen2 := splitMix32(42) + for i := range 5 { + if gen1() != gen2() { + t.Errorf("Expected deterministic output at iteration %d", i) + } + } +} + +func TestGenerateLetters_GivenRandom_WhenCalled_ThenReturnsCorrectLengthAndCharset(t *testing.T) { + random := splitMix32(1) + letters := generateLetters(random) + if len(letters) != numLettersInGroup { + t.Errorf("Expected %d letters, got %d", numLettersInGroup, len(letters)) + } + for _, c := range letters { + if !strings.ContainsRune(alphabet, c) { + t.Errorf("Expected letter in alphabet, got %q", c) + } + } +} + +func TestGenerateDigits_GivenRandom_WhenCalled_ThenReturnsCorrectLengthAndDigits(t *testing.T) { + random := splitMix32(2) + digits := generateDigits(random) + if len(digits) != numDigitsInGroup { + t.Errorf("Expected %d digits, got %d", numDigitsInGroup, len(digits)) + } + for _, c := range digits { + if c < '0' || c > '9' { + t.Errorf("Expected digit, got %q", c) + } + } +} + +func TestPlateFromSeed_GivenSeed_WhenCalled_ThenReturnsPlateFormat(t *testing.T) { + plate := plateFromSeed(99) + if !isPlateFormat(plate) { + t.Errorf("Expected plate format, got %q", plate) + } +} + +// isPlateFormat checks if a string matches the XX-000-000-XX format. +func isPlateFormat(s string) bool { + parts := strings.Split(s, "-") + if len(parts) != 4 { + return false + } + if len(parts[0]) != numLettersInGroup || len(parts[1]) != numDigitsInGroup || len(parts[2]) != numDigitsInGroup || len(parts[3]) != numLettersInGroup { + return false + } + for _, c := range parts[0] + parts[3] { + if !strings.ContainsRune(alphabet, c) { + return false + } + } + for _, c := range parts[1] + parts[2] { + if c < '0' || c > '9' { + return false + } + } + return true +}