This commit is contained in:
@@ -31,3 +31,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -v ./...
|
run: go test -v ./...
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -o ./bin/triplex-example ./cmd/triplex-example
|
||||||
|
|
||||||
|
- name: Run example
|
||||||
|
run: ./bin/triplex-example && ./bin/triplex-example --complete ABC-1234-D && echo "Example ran successfully"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -9,7 +9,7 @@ Copyright (c) 2026 Micha Hoiting
|
|||||||
high-integrity identifiers. It maps a compact 32-bit integer space to a
|
high-integrity identifiers. It maps a compact 32-bit integer space to a
|
||||||
human-readable serial format:
|
human-readable serial format:
|
||||||
|
|
||||||
```
|
```text
|
||||||
LLL-NNN-LC
|
LLL-NNN-LC
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -18,6 +18,12 @@ Where:
|
|||||||
- `N` = number (`100`–`999`)
|
- `N` = number (`100`–`999`)
|
||||||
- `C` = checksum letter (`A`–`Z`)
|
- `C` = checksum letter (`A`–`Z`)
|
||||||
|
|
||||||
|
For instance
|
||||||
|
|
||||||
|
```text
|
||||||
|
ABC-123-DT
|
||||||
|
```
|
||||||
|
|
||||||
## What it provides
|
## What it provides
|
||||||
|
|
||||||
- 4 data letters
|
- 4 data letters
|
||||||
@@ -77,6 +83,8 @@ func main() {
|
|||||||
- Parses and validates `LLL-NNN-LC`, checks checksum, and returns the deterministic index.
|
- Parses and validates `LLL-NNN-LC`, checks checksum, and returns the deterministic index.
|
||||||
- `serial.Decode(idx uint32) (string, error)`
|
- `serial.Decode(idx uint32) (string, error)`
|
||||||
- Converts a valid index back to `LLL-NNN-LC`.
|
- Converts a valid index back to `LLL-NNN-LC`.
|
||||||
|
- `serial.CompleteCode(codeWithoutChecksum string) (string, uint32, error)`
|
||||||
|
- Takes `LLL-NNN-L` and returns the completed `LLL-NNN-LC` plus index.
|
||||||
- `serial.RandomCode(isInUse ...func(uint32) bool) (string, uint32, error)`
|
- `serial.RandomCode(isInUse ...func(uint32) bool) (string, uint32, error)`
|
||||||
- Generates a random valid `LLL-NNN-LC` code and its corresponding index.
|
- Generates a random valid `LLL-NNN-LC` code and its corresponding index.
|
||||||
- If provided, the callback is used to skip indices already in use by the client.
|
- If provided, the callback is used to skip indices already in use by the client.
|
||||||
@@ -108,6 +116,7 @@ make test
|
|||||||
Exported errors:
|
Exported errors:
|
||||||
|
|
||||||
- `serial.ErrInvalidFormat`
|
- `serial.ErrInvalidFormat`
|
||||||
|
- `serial.ErrInvalidFormatNoChecksum`
|
||||||
- `serial.ErrInvalidLetters`
|
- `serial.ErrInvalidLetters`
|
||||||
- `serial.ErrForbiddenLetterTriplet`
|
- `serial.ErrForbiddenLetterTriplet`
|
||||||
- `serial.ErrInvalidNumber`
|
- `serial.ErrInvalidNumber`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.hoiting.org/micha/triplex/serial"
|
"git.hoiting.org/micha/triplex/serial"
|
||||||
@@ -29,6 +30,21 @@ func myRandomIndex(max uint32) (uint32, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
complete := flag.String("complete", "", "complete code without checksum in format LLL-NNN-L")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *complete != "" {
|
||||||
|
full, idx, err := serial.CompleteCode(*complete)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("complete error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("code:", full)
|
||||||
|
fmt.Println("idx:", idx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
opts := serial.RandomCodeOptions{
|
opts := serial.RandomCodeOptions{
|
||||||
MaxAttempts: 500,
|
MaxAttempts: 500,
|
||||||
IsInUse: func(idx uint32) bool {
|
IsInUse: func(idx uint32) bool {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidFormat = errors.New("invalid format, expected LLL-NNN-LC")
|
ErrInvalidFormat = errors.New("invalid format, expected LLL-NNN-LC")
|
||||||
|
ErrInvalidFormatNoChecksum = errors.New("invalid format, expected LLL-NNN-L")
|
||||||
ErrInvalidLetters = errors.New("invalid letters")
|
ErrInvalidLetters = errors.New("invalid letters")
|
||||||
ErrForbiddenLetterTriplet = errors.New("forbidden letter combination")
|
ErrForbiddenLetterTriplet = errors.New("forbidden letter combination")
|
||||||
ErrInvalidNumber = errors.New("invalid number")
|
ErrInvalidNumber = errors.New("invalid number")
|
||||||
@@ -112,6 +113,40 @@ func Decode(idx uint32) (string, error) {
|
|||||||
return code, nil
|
return code, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CompleteCode(codeWithoutChecksum string) (string, uint32, error) {
|
||||||
|
if len(codeWithoutChecksum) != 9 || codeWithoutChecksum[3] != '-' || codeWithoutChecksum[7] != '-' {
|
||||||
|
return "", 0, ErrInvalidFormatNoChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
l1, l2, l3, l4 := codeWithoutChecksum[0], codeWithoutChecksum[1], codeWithoutChecksum[2], codeWithoutChecksum[8]
|
||||||
|
n1, n2, n3 := codeWithoutChecksum[4], codeWithoutChecksum[5], codeWithoutChecksum[6]
|
||||||
|
|
||||||
|
if !isUpperAlphaASCII(l1) || !isUpperAlphaASCII(l2) || !isUpperAlphaASCII(l3) || !isUpperAlphaASCII(l4) {
|
||||||
|
return "", 0, ErrInvalidLetters
|
||||||
|
}
|
||||||
|
if IsForbiddenTriplet(l1, l2, l3) {
|
||||||
|
return "", 0, ErrForbiddenLetterTriplet
|
||||||
|
}
|
||||||
|
if !isNumber(n1) || !isNumber(n2) || !isNumber(n3) {
|
||||||
|
return "", 0, ErrInvalidNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
L1 := uint32(l1 - 'A')
|
||||||
|
L2 := uint32(l2 - 'A')
|
||||||
|
L3 := uint32(l3 - 'A')
|
||||||
|
L4 := uint32(l4 - 'A')
|
||||||
|
num := uint32(n1-'0')*100 + uint32(n2-'0')*10 + uint32(n3-'0')
|
||||||
|
if num < 100 || num > 999 {
|
||||||
|
return "", 0, ErrNumberOutOfRange
|
||||||
|
}
|
||||||
|
N := num - 100
|
||||||
|
|
||||||
|
idx := uint32((((((L1*letters+L2)*letters+L3)*letters + L4) * numbers) + N))
|
||||||
|
checksum := checksumLetter(idx)
|
||||||
|
|
||||||
|
return codeWithoutChecksum + string(checksum), idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
func RandomCode(isInUse ...func(uint32) bool) (string, uint32, error) {
|
func RandomCode(isInUse ...func(uint32) bool) (string, uint32, error) {
|
||||||
options := RandomCodeOptions{}
|
options := RandomCodeOptions{}
|
||||||
if len(isInUse) > 0 {
|
if len(isInUse) > 0 {
|
||||||
|
|||||||
@@ -192,6 +192,38 @@ func TestEncodeRejectsNonDigitInNumber(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompleteCodeRoundtrip(t *testing.T) {
|
||||||
|
original, err := serial.Decode(123456)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutChecksum := original[:len(original)-1]
|
||||||
|
full, idx, err := serial.CompleteCode(withoutChecksum)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteCode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if full != original {
|
||||||
|
t.Fatalf("completed code mismatch: got %q, want %q", full, original)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx2, err := serial.Encode(full)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode failed for completed code %q: %v", full, err)
|
||||||
|
}
|
||||||
|
if idx2 != idx {
|
||||||
|
t.Fatalf("idx mismatch: got %d, want %d", idx, idx2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteCodeRejectsInvalidFormat(t *testing.T) {
|
||||||
|
_, _, err := serial.CompleteCode("AAA-10-A")
|
||||||
|
if !errors.Is(err, serial.ErrInvalidFormatNoChecksum) {
|
||||||
|
t.Fatalf("expected invalid no-checksum format error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRandomCodeRoundtrip(t *testing.T) {
|
func TestRandomCodeRoundtrip(t *testing.T) {
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
code, idx, err := serial.RandomCode()
|
code, idx, err := serial.RandomCode()
|
||||||
|
|||||||
Reference in New Issue
Block a user