From 22ca29b4af3479c04c6d15e3e2a15b41f7211cb1 Mon Sep 17 00:00:00 2001 From: micha Date: Sat, 21 Feb 2026 04:19:19 +0100 Subject: [PATCH] Added CompleteCode function. --- .gitea/workflows/ci.yml | 8 ++++++ README.md | 11 +++++++- cmd/triplex-example/main.go | 16 +++++++++++ serial/serial.go | 53 ++++++++++++++++++++++++++++++------- serial/serial_test.go | 32 ++++++++++++++++++++++ 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index faec306..e59298c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,3 +31,11 @@ jobs: - name: Test 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" + + diff --git a/README.md b/README.md index 0a0aa85..a6eda42 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Copyright (c) 2026 Micha Hoiting high-integrity identifiers. It maps a compact 32-bit integer space to a human-readable serial format: -``` +```text LLL-NNN-LC ``` @@ -18,6 +18,12 @@ Where: - `N` = number (`100`–`999`) - `C` = checksum letter (`A`–`Z`) +For instance + +```text +ABC-123-DT +``` + ## What it provides - 4 data letters @@ -77,6 +83,8 @@ func main() { - Parses and validates `LLL-NNN-LC`, checks checksum, and returns the deterministic index. - `serial.Decode(idx uint32) (string, error)` - 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)` - 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. @@ -108,6 +116,7 @@ make test Exported errors: - `serial.ErrInvalidFormat` +- `serial.ErrInvalidFormatNoChecksum` - `serial.ErrInvalidLetters` - `serial.ErrForbiddenLetterTriplet` - `serial.ErrInvalidNumber` diff --git a/cmd/triplex-example/main.go b/cmd/triplex-example/main.go index c150e20..aac91e8 100644 --- a/cmd/triplex-example/main.go +++ b/cmd/triplex-example/main.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/binary" "errors" + "flag" "fmt" "git.hoiting.org/micha/triplex/serial" @@ -29,6 +30,21 @@ func myRandomIndex(max uint32) (uint32, error) { } 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{ MaxAttempts: 500, IsInUse: func(idx uint32) bool { diff --git a/serial/serial.go b/serial/serial.go index 4dc5598..5f0cef5 100644 --- a/serial/serial.go +++ b/serial/serial.go @@ -19,15 +19,16 @@ const ( ) var ( - ErrInvalidFormat = errors.New("invalid format, expected LLL-NNN-LC") - ErrInvalidLetters = errors.New("invalid letters") - ErrForbiddenLetterTriplet = errors.New("forbidden letter combination") - ErrInvalidNumber = errors.New("invalid number") - ErrNumberOutOfRange = errors.New("number out of range") - ErrInvalidChecksum = errors.New("invalid checksum") - ErrIndexOutOfRange = errors.New("index out of range") - ErrRandomSourceFailed = errors.New("random source failed") - ErrNoAvailableIndex = errors.New("no available index found") + ErrInvalidFormat = errors.New("invalid format, expected LLL-NNN-LC") + ErrInvalidFormatNoChecksum = errors.New("invalid format, expected LLL-NNN-L") + ErrInvalidLetters = errors.New("invalid letters") + ErrForbiddenLetterTriplet = errors.New("forbidden letter combination") + ErrInvalidNumber = errors.New("invalid number") + ErrNumberOutOfRange = errors.New("number out of range") + ErrInvalidChecksum = errors.New("invalid checksum") + ErrIndexOutOfRange = errors.New("index out of range") + ErrRandomSourceFailed = errors.New("random source failed") + ErrNoAvailableIndex = errors.New("no available index found") ) type RandomCodeOptions struct { @@ -112,6 +113,40 @@ func Decode(idx uint32) (string, error) { 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) { options := RandomCodeOptions{} if len(isInUse) > 0 { diff --git a/serial/serial_test.go b/serial/serial_test.go index bca805c..8231ac3 100644 --- a/serial/serial_test.go +++ b/serial/serial_test.go @@ -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) { for i := 0; i < 100; i++ { code, idx, err := serial.RandomCode()