Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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!
13 changes: 13 additions & 0 deletions examples/basic/main.go
Original file line number Diff line number Diff line change
@@ -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"))
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/Mathious6/platekit

go 1.24.3
88 changes: 88 additions & 0 deletions platekit.go
Original file line number Diff line number Diff line change
@@ -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)
}
115 changes: 115 additions & 0 deletions platekit_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading