diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..43ec7c1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Restore
+ run: dotnet restore SequentialRadixCodec.slnx
+
+ - name: Build
+ run: dotnet build SequentialRadixCodec.slnx -c Release --no-restore
+
+ - name: Test
+ run: dotnet test SequentialRadixCodec.slnx -c Release --no-build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b8c8187
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,49 @@
+name: Release
+
+on:
+ release:
+ types: [ published ]
+
+permissions:
+ contents: read
+ id-token: write # required for the GitHub OIDC token used by Trusted Publishing
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Derive version from release tag
+ id: version
+ run: |
+ tag="${{ github.event.release.tag_name }}"
+ echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Test
+ run: dotnet test SequentialRadixCodec.slnx -c Release
+
+ - name: Pack
+ run: >
+ dotnet pack src/SequentialRadixCodec/SequentialRadixCodec.csproj
+ -c Release
+ -p:Version=${{ steps.version.outputs.version }}
+ -o artifacts
+
+ - name: NuGet login (OIDC -> short-lived API key)
+ uses: NuGet/login@v1
+ id: login
+ with:
+ user: ${{ vars.NUGET_USER }}
+
+ - name: Push to NuGet.org
+ run: >
+ dotnet nuget push "artifacts/*.nupkg"
+ --api-key ${{ steps.login.outputs.NUGET_API_KEY }}
+ --source https://api.nuget.org/v3/index.json
+ --skip-duplicate
diff --git a/.gitignore b/.gitignore
index dfcfd56..7acee31 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
-## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
@@ -9,6 +9,9 @@
*.user
*.userosscache
*.sln.docstates
+*.env
+# Jetbrains Rider
+*.idea
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
@@ -23,14 +26,22 @@ mono_crash.*
[Rr]eleases/
x64/
x86/
+[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
+[Aa][Rr][Mm]64[Ee][Cc]/
bld/
-[Bb]in/
[Oo]bj/
+[Oo]ut/
[Ll]og/
[Ll]ogs/
+# Build results on 'Bin' directories
+**/[Bb]in/*
+# Uncomment if you have tasks that rely on *.refresh files to move binaries
+# (https://github.com/github/gitignore/pull/3736)
+#!**/[Bb]in/*.refresh
+
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
@@ -42,12 +53,16 @@ Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
+*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
+# Approval Tests result files
+*.received.*
+
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
@@ -61,6 +76,9 @@ project.lock.json
project.fragment.lock.json
artifacts/
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
# StyleCop
StyleCopReport.xml
@@ -71,6 +89,7 @@ StyleCopReport.xml
*.ilk
*.meta
*.obj
+*.idb
*.iobj
*.pch
*.pdb
@@ -78,6 +97,8 @@ StyleCopReport.xml
*.pgc
*.pgd
*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
*.sbr
*.tlb
*.tli
@@ -86,6 +107,7 @@ StyleCopReport.xml
*.tmp_proj
*_wpftmp.csproj
*.log
+*.tlog
*.vspscc
*.vssscc
.builds
@@ -137,12 +159,18 @@ _TeamCity*
.axoCover/*
!.axoCover/settings.json
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
+.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
@@ -284,6 +312,17 @@ node_modules/
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
@@ -293,22 +332,22 @@ node_modules/
_Pvt_Extensions
# Paket dependency manager
-.paket/paket.exe
+**/.paket/paket.exe
paket-files/
# FAKE - F# Make
-.fake/
+**/.fake/
# CodeRush personal settings
-.cr/personal
+**/.cr/personal
# Python Tools for Visual Studio (PTVS)
-__pycache__/
+**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
-# tools/**
-# !tools/packages.config
+#tools/**
+#!tools/packages.config
# Tabs Studio
*.tss
@@ -330,15 +369,22 @@ ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
+MSBuild_Logs/
+
+# AWS SAM Build and Temporary Artifacts folder
+.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
-.mfractor/
+**/.mfractor/
# Local History for Visual Studio
-.localhistory/
+**/.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
@@ -347,4 +393,28 @@ healthchecksdb
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
-.ionide/
+**/.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..0bcf3bd
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ latest
+ enable
+ disable
+ true
+ true
+
+
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 474bc20..3323160 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2020 Kelby Hunt
+Copyright (c) 2026-* Kelby Hunt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
index d96548b..ff26921 100644
--- a/README.md
+++ b/README.md
@@ -1,68 +1,126 @@
-# Serial Number Generator
+# Sequential Radix Codec
-[](https://dev.azure.com/huntk20/serial-number-generator/_build/latest?definitionId=1&branchName=master)
+[](https://github.com/Huntk23/SequentialRadixCodec/actions/workflows/ci.yml)
+[](https://www.nuget.org/packages/SequentialRadixCodec)
-A .NET Standard library for generating sequential encoded serial numbers by using base conversion of a number to its serial number format representation.
+A .NET library for **sequential, sortable base-N (radix) encoding** over a custom
+alphabet. It maps non-negative integers to left-padded, monotonically increasing
+codes and back - for serial numbers, license keys, and sortable short IDs.
-# Usage
+Targets `net10.0` and `netstandard2.0`. The `net10.0` build adds a
+`FrozenDictionary` lookup, AOT/trim compatibility, and a zero-allocation
+`TryEncode(Span)`.
-```cs
-using SerialNumberGenerator;
-```
+> **NOTE**: Codes are **predictable and reversible**: the next code is trivially guessable
+> and any code decodes straight back to its number. Don't use this where you need
+> **unguessable or non-enumerable** IDs (use a GUID) or **obfuscated** IDs that hide
+> the underlying sequence (use Hashids/Sqids). It also doesn't *generate* uniqueness;
+> feed it unique numbers (e.g. a database sequence) and the codes are unique; feed it
+> duplicates and they will collide.
-## Pre-built Formats
+## Install
-Three formats come predefined in the library.
+```
+dotnet add package SequentialRadixCodec
+```
-1. `Base10`: Using numbers 0-9
-2. `Base26`: Using letters A-Z
-3. `Base36`: Using numbers 0-9 and then A-Z
+## Pre-built codecs
-### Base26 Example
+| Codec | Alphabet | Min width |
+|---|---|---|
+| `RadixCodec.Base10` | `0-9` | 5 |
+| `RadixCodec.Base26` | `A-Z` | 5 |
+| `RadixCodec.Base36` | `0-9A-Z` | 5 |
-```cs
-var serialNumber = SerialNumberFormat.Base26.Encode(1001);
+## Encode / Decode
-// Result: AABMN
+```csharp
+using SequentialRadixCodec;
-var numericSerialNumber = serialNumberFormat.Decode(serialNumber);
+string code = RadixCodec.Base26.Encode(1001); // "AABMN"
-// Result: 1001
+// Pick the return width you want (no BigInteger required):
+int i = RadixCodec.Base26.DecodeInt32("AABMN"); // 1001
+long l = RadixCodec.Base26.DecodeInt64("AABMN"); // 1001
+BigInteger b = RadixCodec.Base26.DecodeBigInteger("AABMN"); // 1001
+
+// Non-throwing decode (false on null/empty/invalid/out-of-range):
+if (RadixCodec.Base26.TryDecodeInt64(userInput, out long value)) { }
```
-## Custom Format
+`DecodeInt32` / `DecodeInt64` throw `OverflowException` if the decoded value exceeds
+`int.MaxValue` / `long.MaxValue`; the `TryDecode*` variants return `false` instead.
+`DecodeBigInteger` never overflows, so values beyond `int`/`long` round-trip cleanly.
+
+## Custom Alphabet
-```cs
-var serialNumberFormat = new SerialNumberFormat("02357ABC");
+```csharp
+var codec = new RadixCodec("02357ABC"); // first char '0' is the pad digit
+string code = codec.Encode(1001); // "2CA2"
+BigInteger n = codec.DecodeBigInteger(code); // 1001
+```
-var serialNumber = serialNumberFormat.Encode(1001);
+Custom codecs default to `minLength: 1` (no padding). Pass a `minLength` to pad.
-// Result: 02CA2
+## Padding
-var numericSerialNumber = serialNumberFormat.Decode(serialNumber);
+The minimum width left-pads with `alphabet[0]`; a longer natural encoding is never
+truncated. Override per call:
-// Result: 1001
+```csharp
+var codec = new RadixCodec("ABCDEFGHIJKLMNOPQRSTUVWXYZ", minLength: 4);
+codec.Encode(1001); // "ABMN"
+// Override call
+codec.Encode(1001, 8); // "AAAAABMN"
+```
-foreach (var generatedSerialNumber in serialNumberFormat.Generate().Skip(1000 + 1).Take(100))
-{
- Console.Write($"{generatedSerialNumber} ");
-}
+## Generating Sequences
+
+```csharp
+// Next 100 Base26 codes after a stored value:
+BigInteger last = RadixCodec.Base26.DecodeBigInteger("AABMM");
+foreach (var code in RadixSequence.From(RadixCodec.Base26, start: last + 1, count: 100))
+ Console.Write($"{code} ");
-// Result: 02CA2 02CA3 02CA5 02CA7 02CAA 02CAB 02CAC 02CB0 02CB2 02CB3 02CB5 02CB7 02CBA 02CBB 02CBC 02CC0 02CC2 02CC3 02CC5 02CC7 02CCA 02CCB 02CCC 03000 03002 03003 03005 03007 0300A 0300B 0300C 03020 03022 03023 03025 03027 0302A 0302B 0302C 03030 03032 03033 03035 03037 0303A 0303B 0303C 03050 03052 03053 03055 03057 0305A 0305B 0305C 03070 03072 03073 03075 03077 0307A 0307B 0307C 030A0 030A2 030A3 030A5 030A7 030AA 030AB 030AC 030B0 030B2 030B3 030B5 030B7 030BA 030BB 030BC 030C0 030C2 030C3 030C5 030C7 030CA 030CB 030CC 03200 03202 03203 03205 03207 0320A 0320B 0320C 03220 03222 03223 03225 03227
+// Unbounded + LINQ:
+var page = RadixSequence.From(RadixCodec.Base10).Skip(1000).Take(50);
```
-## Visual Padding Explained
+## Zero-allocation Enumeration (net10.0+)
-All pre-built formats and any custom formats not using the visual padding length override has a default padding of 5. If a number is converted to a representation shorter than 5 characters, the first character in the format is prepended to the output.
+Stream consecutive codes into a reused buffer — no per-item string allocation, no
+`BigInteger`. `Current` is valid only until the next iteration.
-### Custom Base26 Example
+```csharp
+// Caller owns the buffer (zero heap allocation):
+Span buf = stackalloc char[16];
+foreach (ReadOnlySpan code in RadixSequence.EnumerateInto(RadixCodec.Base26, buf, start: 1001, count: 100))
+{
+ // Do something with "code"
+}
-Compared to the earlier example using the default `Base26` conversion.
+// Pooled buffer (no sizing needed; foreach returns it to the pool):
+foreach (ReadOnlySpan code in RadixSequence.Enumerate(RadixCodec.Base36, start: 10_000_000_000))
+{
+ // Do Something with "code"
+}
+```
-```cs
-var customBase26 = new SerialNumberFormat("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 2); // Padding is set to 2
+`start`/`count` are `long` (`count` omitted = unbounded). For `EnumerateInto`, a bounded
+range whose largest code cannot fit the buffer throws `ArgumentException` up front; an
+unbounded range stops when the next code no longer fits.
-var serialNumber = customBase26.Encode(1001);
+## Zero-allocation Encode (net10.0+)
-// Result: BMN
+```csharp
+Span buffer = stackalloc char[16];
+if (RadixCodec.Base36.TryEncode(value, buffer, out int written))
+{
+ // Do something with "buffer[..written];"
+}
+
```
+
+## License
+
+[MIT](LICENSE)
\ No newline at end of file
diff --git a/SNG.sln b/SNG.sln
deleted file mode 100644
index 3055465..0000000
--- a/SNG.sln
+++ /dev/null
@@ -1,70 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.26228.9
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerialNumberGenerator", "src\SerialNumberGenerator.csproj", "{4419D1B4-212D-41CE-96EA-4EA54AC61A1E}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerialNumberGenerator.ConsoleTest", "test\SequentialSerialNumberGenerator.ConsoleTest\SerialNumberGenerator.ConsoleTest.csproj", "{BB37390A-BA7D-4D7A-A6FB-079B64B009E3}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerialNumberGenerator.Test", "test\SequentialSerialNumberGenerator.Test\SerialNumberGenerator.Test.csproj", "{B3A118DF-1E7A-4295-8BD4-FC402B6220AD}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5145F930-382D-47F3-9D18-3338F906ABFB}"
- ProjectSection(SolutionItems) = preProject
- README.md = README.md
- EndProjectSection
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Debug|x64 = Debug|x64
- Debug|x86 = Debug|x86
- Release|Any CPU = Release|Any CPU
- Release|x64 = Release|x64
- Release|x86 = Release|x86
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Debug|x64.ActiveCfg = Debug|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Debug|x64.Build.0 = Debug|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Debug|x86.ActiveCfg = Debug|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Debug|x86.Build.0 = Debug|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Release|Any CPU.Build.0 = Release|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Release|x64.ActiveCfg = Release|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Release|x64.Build.0 = Release|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Release|x86.ActiveCfg = Release|Any CPU
- {4419D1B4-212D-41CE-96EA-4EA54AC61A1E}.Release|x86.Build.0 = Release|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Debug|x64.ActiveCfg = Debug|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Debug|x64.Build.0 = Debug|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Debug|x86.ActiveCfg = Debug|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Debug|x86.Build.0 = Debug|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Release|Any CPU.Build.0 = Release|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Release|x64.ActiveCfg = Release|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Release|x64.Build.0 = Release|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Release|x86.ActiveCfg = Release|Any CPU
- {BB37390A-BA7D-4D7A-A6FB-079B64B009E3}.Release|x86.Build.0 = Release|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Debug|x64.ActiveCfg = Debug|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Debug|x64.Build.0 = Debug|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Debug|x86.ActiveCfg = Debug|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Debug|x86.Build.0 = Debug|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Release|Any CPU.Build.0 = Release|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Release|x64.ActiveCfg = Release|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Release|x64.Build.0 = Release|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Release|x86.ActiveCfg = Release|Any CPU
- {B3A118DF-1E7A-4295-8BD4-FC402B6220AD}.Release|x86.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {999A8BA2-535C-4248-96E2-97CCF612B692}
- EndGlobalSection
-EndGlobal
diff --git a/SequentialRadixCodec.slnx b/SequentialRadixCodec.slnx
new file mode 100644
index 0000000..c5dabbf
--- /dev/null
+++ b/SequentialRadixCodec.slnx
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
deleted file mode 100644
index 52d266e..0000000
--- a/azure-pipelines.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-trigger:
-- master
-
-pool:
- vmImage: 'windows-latest'
-
-variables:
- solution: '**/*.sln'
- buildPlatform: 'Any CPU'
- buildConfiguration: 'Release'
-
-steps:
-- task: NuGetToolInstaller@1
-
-- task: NuGetCommand@2
- inputs:
- restoreSolution: '$(solution)'
-
-- task: VSBuild@1
- inputs:
- solution: '$(solution)'
- platform: '$(buildPlatform)'
- configuration: '$(buildConfiguration)'
-
-- task: VSTest@2
- inputs:
- platform: '$(buildPlatform)'
- configuration: '$(buildConfiguration)'
- testAssemblyVer2: |
- **\*test*.dll
- !**\*TestAdapter.dll
- !**\*ConsoleTest.dll
- !**\obj\**
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..de9f9b5
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.301",
+ "rollForward": "latestFeature"
+ }
+}
\ No newline at end of file
diff --git a/samples/SequentialRadixCodec.Sample/Program.cs b/samples/SequentialRadixCodec.Sample/Program.cs
new file mode 100644
index 0000000..506cbd1
--- /dev/null
+++ b/samples/SequentialRadixCodec.Sample/Program.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Numerics;
+
+namespace SequentialRadixCodec.Sample;
+
+internal static class Program
+{
+ private static void Main()
+ {
+ Console.WriteLine("Base26 - decode a stored code, then generate the next 10");
+ Console.WriteLine("---------------------------------------------------------");
+
+ // ReSharper disable once StringLiteralTypo
+ const string lastCode = "AABMM";
+ BigInteger last = RadixCodec.Base26.DecodeBigInteger(lastCode);
+ Console.WriteLine($"Last code: {lastCode} -> {last}");
+
+ foreach (var code in RadixSequence.From(RadixCodec.Base26, start: last + 1, count: 10))
+ Console.Write($"{code} ");
+ Console.WriteLine();
+ Console.WriteLine();
+
+ Console.WriteLine("Base10 - codes 1001..1010");
+ Console.WriteLine("-------------------------");
+ foreach (var code in RadixSequence.From(RadixCodec.Base10, start: 1001, count: 10))
+ Console.Write($"{code} ");
+ Console.WriteLine();
+ Console.WriteLine();
+
+ Console.WriteLine("Custom alphabet '02357ABC' - encode/decode round trip");
+ Console.WriteLine("-----------------------------------------------------");
+ var custom = new RadixCodec("02357ABC");
+ var encoded = custom.Encode(1001);
+ Console.WriteLine($"1001 -> {encoded} -> {custom.DecodeBigInteger(encoded)}");
+
+ // Only wait for a keypress when a human is actually at the console.
+ if (!Console.IsInputRedirected)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Press any key to exit...");
+ Console.ReadKey();
+ }
+ }
+}
diff --git a/samples/SequentialRadixCodec.Sample/SequentialRadixCodec.Sample.csproj b/samples/SequentialRadixCodec.Sample/SequentialRadixCodec.Sample.csproj
new file mode 100644
index 0000000..f6f8337
--- /dev/null
+++ b/samples/SequentialRadixCodec.Sample/SequentialRadixCodec.Sample.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ net10.0
+ false
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SequentialRadixCodec/RadixCodePooledEnumerator.cs b/src/SequentialRadixCodec/RadixCodePooledEnumerator.cs
new file mode 100644
index 0000000..b4bfffc
--- /dev/null
+++ b/src/SequentialRadixCodec/RadixCodePooledEnumerator.cs
@@ -0,0 +1,76 @@
+#if NET10_0_OR_GREATER
+using System;
+using System.Buffers;
+
+namespace SequentialRadixCodec;
+
+///
+/// Forward enumerator over consecutive codes backed by an
+/// buffer the caller does not have to size. Dispose (or foreach) returns the
+/// buffer to the pool. is valid only until the next .
+///
+public ref struct RadixCodePooledEnumerator
+{
+ private readonly RadixCodec _codec;
+ private readonly long _start;
+ private readonly bool _bounded;
+ private readonly int _capacity;
+ private long _remaining;
+ private char[]? _rented;
+ private int _length;
+ private bool _started;
+
+ internal RadixCodePooledEnumerator(RadixCodec codec, long start, long? count, int capacity)
+ {
+ _codec = codec;
+ _start = start;
+ _bounded = count.HasValue;
+ _remaining = count ?? 0;
+ _capacity = capacity;
+ _rented = null;
+ _length = 0;
+ _started = false;
+ }
+
+ /// The current code. Valid only until the next .
+ public readonly ReadOnlySpan Current =>
+ _rented is null ? ReadOnlySpan.Empty : _rented.AsSpan(_rented.Length - _length, _length);
+
+ /// Returns this enumerator, enabling foreach.
+ public readonly RadixCodePooledEnumerator GetEnumerator() => this;
+
+ /// Advances to the next code.
+ public bool MoveNext()
+ {
+ if (_bounded && _remaining == 0)
+ return false;
+
+ if (!_started)
+ {
+ _started = true;
+ _rented = ArrayPool.Shared.Rent(_capacity);
+ if (!_codec.TrySeed(_rented, _start, out _length))
+ return false;
+ }
+ else if (!_codec.TryIncrement(_rented!, ref _length))
+ {
+ return false;
+ }
+
+ if (_bounded)
+ _remaining--;
+
+ return true;
+ }
+
+ /// Returns the pooled buffer. Called automatically by foreach.
+ public void Dispose()
+ {
+ if (_rented is not null)
+ {
+ ArrayPool.Shared.Return(_rented);
+ _rented = null;
+ }
+ }
+}
+#endif
diff --git a/src/SequentialRadixCodec/RadixCodeSpanEnumerator.cs b/src/SequentialRadixCodec/RadixCodeSpanEnumerator.cs
new file mode 100644
index 0000000..c3a86a1
--- /dev/null
+++ b/src/SequentialRadixCodec/RadixCodeSpanEnumerator.cs
@@ -0,0 +1,60 @@
+#if NET10_0_OR_GREATER
+using System;
+
+namespace SequentialRadixCodec;
+
+///
+/// Forward enumerator over consecutive codes that writes each into a caller-provided buffer (zero heap allocation).
+/// is valid only until the next . Use via foreach.
+///
+public ref struct RadixCodeSpanEnumerator
+{
+ private readonly RadixCodec _codec;
+ private readonly Span _buffer;
+ private readonly long _start;
+ private readonly bool _bounded;
+ private long _remaining;
+ private int _length;
+ private bool _started;
+
+ internal RadixCodeSpanEnumerator(RadixCodec codec, Span buffer, long start, long? count)
+ {
+ _codec = codec;
+ _buffer = buffer;
+ _start = start;
+ _bounded = count.HasValue;
+ _remaining = count ?? 0;
+ _length = 0;
+ _started = false;
+ }
+
+ /// The current code. Valid only until the next .
+ public readonly ReadOnlySpan Current => _buffer.Slice(_buffer.Length - _length, _length);
+
+ /// Returns this enumerator, enabling foreach.
+ public readonly RadixCodeSpanEnumerator GetEnumerator() => this;
+
+ /// Advances to the next code. Returns false at the end, or (unbounded) when the buffer cannot hold the next code.
+ public bool MoveNext()
+ {
+ if (_bounded && _remaining == 0)
+ return false;
+
+ if (!_started)
+ {
+ _started = true;
+ if (!_codec.TrySeed(_buffer, _start, out _length))
+ return false;
+ }
+ else if (!_codec.TryIncrement(_buffer, ref _length))
+ {
+ return false;
+ }
+
+ if (_bounded)
+ _remaining--;
+
+ return true;
+ }
+}
+#endif
diff --git a/src/SequentialRadixCodec/RadixCodec.cs b/src/SequentialRadixCodec/RadixCodec.cs
new file mode 100644
index 0000000..40a3858
--- /dev/null
+++ b/src/SequentialRadixCodec/RadixCodec.cs
@@ -0,0 +1,380 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+#if NET10_0_OR_GREATER
+using System.Collections.Frozen;
+#endif
+
+namespace SequentialRadixCodec;
+
+///
+/// Immutable positional base-N (radix) codec over a custom, ordered alphabet.
+/// The first character of the alphabet is both the zero digit and the pad character.
+///
+public sealed class RadixCodec : IEquatable
+{
+#if NET10_0_OR_GREATER
+ private readonly FrozenDictionary _lookup;
+#else
+ private readonly Dictionary _lookup;
+#endif
+
+ /// Digits 0-9, padded to a minimum width of 5.
+ public static readonly RadixCodec Base10 = new("0123456789", 5);
+
+ /// Letters A-Z, padded to a minimum width of 5.
+ // ReSharper disable once StringLiteralTypo
+ public static readonly RadixCodec Base26 = new("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 5);
+
+ /// Digits 0-9 then letters A-Z, padded to a minimum width of 5.
+ public static readonly RadixCodec Base36 = new("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 5);
+
+ /// Creates a codec over the given alphabet.
+ /// Ordered, unique characters (at least 2). alphabet[0] is the zero/pad digit.
+ /// Minimum encoded width; output is left-padded with alphabet[0]. Must be at least 1.
+ public RadixCodec(string alphabet, int minLength = 1)
+ {
+ if (alphabet is null)
+ throw new ArgumentNullException(nameof(alphabet));
+
+ if (alphabet.Length < 2)
+ throw new ArgumentException("Alphabet must contain at least 2 characters.", nameof(alphabet));
+ if (minLength < 1)
+ throw new ArgumentOutOfRangeException(nameof(minLength), minLength, "minLength must be at least 1.");
+
+ var lookup = new Dictionary(alphabet.Length);
+
+ for (int i = 0; i < alphabet.Length; i++)
+ {
+ if (lookup.ContainsKey(alphabet[i]))
+ throw new ArgumentException("Alphabet must contain unique characters.", nameof(alphabet));
+
+ lookup[alphabet[i]] = i;
+ }
+
+#if NET10_0_OR_GREATER
+ _lookup = lookup.ToFrozenDictionary();
+#else
+ _lookup = lookup;
+#endif
+ Alphabet = alphabet;
+ MinLength = minLength;
+ }
+
+ /// The ordered alphabet backing this codec.
+ public string Alphabet { get; }
+
+ /// The radix (number of distinct digits) — the alphabet length.
+ public int Radix => Alphabet.Length;
+
+ /// Minimum encoded width.
+ public int MinLength { get; }
+
+ /// The pad / zero-digit character (Alphabet[0]).
+ public char PadChar => Alphabet[0];
+
+ /// Encodes a non-negative integer into this codec's alphabet.
+ public string Encode(int value, int? minLengthOverride = null) => Encode((BigInteger) value, minLengthOverride);
+
+ /// Encodes a non-negative integer into this codec's alphabet.
+ public string Encode(long value, int? minLengthOverride = null) => Encode((BigInteger) value, minLengthOverride);
+
+ /// Encodes a non-negative integer into this codec's alphabet, left-padded to the effective minimum width.
+ /// is negative, or the width is below 1.
+ public string Encode(BigInteger value, int? minLengthOverride = null)
+ {
+ if (value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "value cannot be negative.");
+
+ int width = minLengthOverride ?? MinLength;
+
+ if (width < 1)
+ throw new ArgumentOutOfRangeException(nameof(minLengthOverride), width, "minLength must be at least 1.");
+
+ if (value.IsZero)
+ return new string(PadChar, width);
+
+ int radix = Radix;
+ var digits = new List(); // least-significant first
+ var remaining = value;
+
+ while (!remaining.IsZero)
+ {
+ int digit = (int) (remaining % radix); // mod BEFORE narrowing — fixes defect 1
+ digits.Add(Alphabet[digit]);
+ remaining /= radix;
+ }
+
+ int natural = digits.Count;
+ int total = Math.Max(width, natural);
+ int padCount = total - natural;
+ var chars = new char[total];
+
+ for (int i = 0; i < padCount; i++)
+ chars[i] = PadChar;
+ for (int i = 0; i < natural; i++)
+ chars[padCount + i] = digits[natural - 1 - i]; // reverse to most significant first
+
+ return new string(chars);
+ }
+
+ /// Decodes a code string back into its value.
+ /// is null.
+ /// is empty or contains a character not in the alphabet.
+ public BigInteger DecodeBigInteger(string code)
+ {
+ if (code is null)
+ throw new ArgumentNullException(nameof(code));
+
+ if (code.Length == 0)
+ throw new ArgumentException("Code cannot be empty.", nameof(code));
+
+ int radix = Radix;
+ BigInteger value = BigInteger.Zero;
+
+ foreach (char c in code)
+ {
+ if (!_lookup.TryGetValue(c, out int digit))
+ throw new ArgumentException($"Character '{c}' is not in the alphabet.", nameof(code));
+
+ value = value * radix + digit;
+ }
+
+ return value;
+ }
+
+ /// Attempts to decode a code string into a . Returns false for null, empty, or invalid input.
+ public bool TryDecodeBigInteger(string? code, out BigInteger value)
+ {
+ value = BigInteger.Zero;
+
+ if (code is null || code.Length == 0)
+ return false;
+
+ int radix = Radix;
+ BigInteger acc = BigInteger.Zero;
+
+ foreach (char c in code)
+ {
+ if (!_lookup.TryGetValue(c, out int digit))
+ return false;
+
+ acc = acc * radix + digit;
+ }
+
+ value = acc;
+
+ return true;
+ }
+
+ /// Decodes a code string into a .
+ /// is null.
+ /// is empty or contains a character not in the alphabet.
+ /// The decoded value exceeds .
+ public long DecodeInt64(string code)
+ {
+ BigInteger value = DecodeBigInteger(code);
+
+ if (value > long.MaxValue)
+ throw new OverflowException($"Decoded value {value} exceeds Int64.MaxValue ({long.MaxValue}).");
+
+ return (long) value;
+ }
+
+ /// Attempts to decode a code string into a . Returns false for null, empty, invalid, or out-of-range input.
+ public bool TryDecodeInt64(string? code, out long value)
+ {
+ value = 0;
+
+ if (!TryDecodeBigInteger(code, out BigInteger big) || big > long.MaxValue)
+ return false;
+
+ value = (long) big;
+
+ return true;
+ }
+
+ /// Decodes a code string into an .
+ /// is null.
+ /// is empty or contains a character not in the alphabet.
+ /// The decoded value exceeds .
+ public int DecodeInt32(string code)
+ {
+ BigInteger value = DecodeBigInteger(code);
+
+ if (value > int.MaxValue)
+ throw new OverflowException($"Decoded value {value} exceeds Int32.MaxValue ({int.MaxValue}).");
+
+ return (int) value;
+ }
+
+ /// Attempts to decode a code string into an . Returns false for null, empty, invalid, or out-of-range input.
+ public bool TryDecodeInt32(string? code, out int value)
+ {
+ value = 0;
+
+ if (!TryDecodeBigInteger(code, out BigInteger big) || big > int.MaxValue)
+ return false;
+
+ value = (int) big;
+
+ return true;
+ }
+
+ ///
+ public bool Equals(RadixCodec? other)
+ => other is not null
+ && MinLength == other.MinLength
+ && string.Equals(Alphabet, other.Alphabet, StringComparison.Ordinal);
+
+ ///
+ public override bool Equals(object? obj) => Equals(obj as RadixCodec);
+
+ ///
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ int hash = StringComparer.Ordinal.GetHashCode(Alphabet);
+ return (hash * 397) ^ MinLength;
+ }
+ }
+
+#if NET10_0_OR_GREATER
+ ///
+ /// Encodes into without allocating.
+ /// Returns false (and writes nothing) if the buffer is too small.
+ ///
+ /// is negative, or the width is below 1.
+ public bool TryEncode(BigInteger value, Span destination, out int charsWritten, int? minLengthOverride = null)
+ {
+ charsWritten = 0;
+
+ if (value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value), value, "value cannot be negative.");
+
+ int width = minLengthOverride ?? MinLength;
+
+ if (width < 1)
+ throw new ArgumentOutOfRangeException(nameof(minLengthOverride), width, "minLength must be at least 1.");
+
+ int radix = Radix;
+ int pos = destination.Length; // fill digits from the right end
+ var remaining = value;
+
+ while (!remaining.IsZero)
+ {
+ if (pos == 0)
+ return false; // not even the natural digits fit
+
+ destination[--pos] = Alphabet[(int) (remaining % radix)];
+ remaining /= radix;
+ }
+
+ int natural = destination.Length - pos;
+ int total = Math.Max(width, natural);
+
+ if (total > destination.Length)
+ return false;
+
+ int digitStart = total - natural;
+
+ // Move digits left into their final window (CopyTo handles overlap), then pad.
+ destination.Slice(destination.Length - natural, natural).CopyTo(destination.Slice(digitStart, natural));
+ destination.Slice(0, digitStart).Fill(PadChar);
+
+ charsWritten = total;
+
+ return true;
+ }
+
+ ///
+ /// Encodes into without allocating.
+ /// Returns false (and writes nothing) if the buffer is too small.
+ ///
+ /// is negative, or the width is below 1.
+ public bool TryEncode(long value, Span destination, out int charsWritten, int? minLengthOverride = null)
+ => TryEncode((BigInteger) value, destination, out charsWritten, minLengthOverride);
+
+ ///
+ /// Encodes into without allocating.
+ /// Returns false (and writes nothing) if the buffer is too small.
+ ///
+ /// is negative, or the width is below 1.
+ public bool TryEncode(int value, Span destination, out int charsWritten, int? minLengthOverride = null)
+ => TryEncode((BigInteger) value, destination, out charsWritten, minLengthOverride);
+
+ ///
+ /// Writes (≥ 0) right-aligned into ,
+ /// left-padded with to , using long
+ /// arithmetic. receives the number of characters written
+ /// (the rightmost ones). Returns false if the code does not fit.
+ ///
+ internal bool TrySeed(Span buffer, long value, out int length)
+ {
+ length = 0;
+
+ int radix = Radix;
+ int pos = buffer.Length; // fill from the right
+
+ if (value == 0)
+ {
+ if (pos == 0)
+ return false;
+ buffer[--pos] = Alphabet[0];
+ }
+ else
+ {
+ for (long remaining = value; remaining > 0; remaining /= radix)
+ {
+ if (pos == 0)
+ return false; // natural digits do not fit
+ buffer[--pos] = Alphabet[(int) (remaining % radix)];
+ }
+ }
+
+ int natural = buffer.Length - pos;
+ int total = Math.Max(MinLength, natural);
+
+ if (total > buffer.Length)
+ return false;
+
+ // Pad the cells immediately left of the natural digits, up to `total` width.
+ for (int i = buffer.Length - total; i < buffer.Length - natural; i++)
+ buffer[i] = PadChar;
+
+ length = total;
+ return true;
+ }
+
+ ///
+ /// Advances the right-aligned code of width in
+ /// by one (odometer). Carries left, growing the width into
+ /// spare cells when needed. Returns false only if the code would exceed
+ /// 's length.
+ ///
+ internal bool TryIncrement(Span buffer, ref int length)
+ {
+ int radix = Radix;
+ int end = buffer.Length - length; // leftmost active cell
+
+ for (int i = buffer.Length - 1; i >= end; i--)
+ {
+ int d = _lookup[buffer[i]];
+ if (d + 1 < radix)
+ {
+ buffer[i] = Alphabet[d + 1];
+ return true;
+ }
+ buffer[i] = Alphabet[0]; // wrap and carry left
+ }
+
+ if (length == buffer.Length)
+ return false; // cannot grow
+
+ length++;
+ buffer[buffer.Length - length] = Alphabet[1];
+ return true;
+ }
+#endif
+}
\ No newline at end of file
diff --git a/src/SequentialRadixCodec/RadixSequence.cs b/src/SequentialRadixCodec/RadixSequence.cs
new file mode 100644
index 0000000..add1b25
--- /dev/null
+++ b/src/SequentialRadixCodec/RadixSequence.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace SequentialRadixCodec;
+
+///
+/// Produces lazy, monotonically increasing sequences of codes from a .
+///
+public static class RadixSequence
+{
+ ///
+ /// Returns codes for , + 1, … encoded with .
+ /// When is null the sequence is unbounded (use LINQ Take to bound it);
+ /// otherwise it yields exactly codes.
+ ///
+ /// is null.
+ /// or is negative.
+ public static IEnumerable From(RadixCodec codec, BigInteger start = default, BigInteger? count = null)
+ {
+ if (codec is null)
+ throw new ArgumentNullException(nameof(codec));
+
+ if (start < 0)
+ throw new ArgumentOutOfRangeException(nameof(start), start, "start cannot be negative.");
+ if (count is { } c && c < 0)
+ throw new ArgumentOutOfRangeException(nameof(count), count, "count cannot be negative.");
+
+ return Iterate(codec, start, count);
+ }
+
+ private static IEnumerable Iterate(RadixCodec codec, BigInteger start, BigInteger? count)
+ {
+ if (count is null)
+ {
+ for (BigInteger i = start;; i++)
+ yield return codec.Encode(i);
+ }
+ else
+ {
+ BigInteger end = start + count.Value;
+ for (BigInteger i = start; i < end; i++)
+ yield return codec.Encode(i);
+ }
+ }
+
+#if NET10_0_OR_GREATER
+ ///
+ /// Enumerates consecutive codes from into a caller-provided with zero heap allocation.
+ /// null is unbounded. For a bounded sequence whose largest code cannot fit the buffer, this throws
+ /// immediately; for an unbounded sequence enumeration stops when a code no longer fits.
+ ///
+ /// is null.
+ /// or is negative, or start+count exceeds Int64.
+ /// A bounded sequence's largest code does not fit .
+ public static RadixCodeSpanEnumerator EnumerateInto(RadixCodec codec, Span buffer, long start = 0, long? count = null)
+ {
+ if (codec is null)
+ throw new ArgumentNullException(nameof(codec));
+ if (start < 0)
+ throw new ArgumentOutOfRangeException(nameof(start), start, "start cannot be negative.");
+ if (count is < 0)
+ throw new ArgumentOutOfRangeException(nameof(count), count, "count cannot be negative.");
+
+ if (count is { } n and > 0)
+ {
+ if (n - 1 > long.MaxValue - start)
+ throw new ArgumentOutOfRangeException(nameof(count), "start + count exceeds the Int64 range.");
+
+ int width = CodeWidth(codec, start + n - 1);
+ if (width > buffer.Length)
+ throw new ArgumentException(
+ $"buffer length {buffer.Length} cannot hold the largest code (needs {width} characters).", nameof(buffer));
+ }
+
+ return new RadixCodeSpanEnumerator(codec, buffer, start, count);
+ }
+
+ ///
+ /// Enumerates consecutive codes from using a pooled buffer the caller does not size.
+ /// null is unbounded. The buffer is sized once to the widest code any value
+ /// can produce for this codec, so it never reallocates. Dispose (or foreach) returns the buffer to the pool.
+ ///
+ /// is null.
+ /// or is negative.
+ public static RadixCodePooledEnumerator Enumerate(RadixCodec codec, long start = 0, long? count = null)
+ {
+ if (codec is null)
+ throw new ArgumentNullException(nameof(codec));
+ if (start < 0)
+ throw new ArgumentOutOfRangeException(nameof(start), start, "start cannot be negative.");
+ if (count is { } c && c < 0)
+ throw new ArgumentOutOfRangeException(nameof(count), count, "count cannot be negative.");
+
+ int capacity = CodeWidth(codec, long.MaxValue);
+
+ return new RadixCodePooledEnumerator(codec, start, count, capacity);
+ }
+
+ /// Number of characters would produce for .
+ private static int CodeWidth(RadixCodec codec, long value)
+ {
+ int radix = codec.Radix;
+ int digits = 1;
+
+ for (long v = value; v >= radix; v /= radix)
+ digits++;
+
+ return Math.Max(codec.MinLength, digits);
+ }
+#endif
+}
\ No newline at end of file
diff --git a/src/SequentialRadixCodec/SequentialRadixCodec.csproj b/src/SequentialRadixCodec/SequentialRadixCodec.csproj
new file mode 100644
index 0000000..02be6ba
--- /dev/null
+++ b/src/SequentialRadixCodec/SequentialRadixCodec.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net10.0;netstandard2.0
+ true
+ SequentialRadixCodec
+ Sequential Radix Codec
+ Sequential, sortable base-N (radix) codec over a custom alphabet. Encodes and decodes non-negative integers to left-padded, monotonically-ordered codes — serial numbers, license keys, sortable short IDs.
+ Kelby Hunt
+ radix;base-n;encode;decode;serial-number;codec;base36;base26;short-id
+ https://github.com/Huntk23/SequentialRadixCodec
+ git
+ README.md
+ MIT
+ true
+ true
+ 1.0.0
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SerialNumberFormat.cs b/src/SerialNumberFormat.cs
deleted file mode 100644
index c8d8092..0000000
--- a/src/SerialNumberFormat.cs
+++ /dev/null
@@ -1,192 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Numerics;
-
-namespace SerialNumberGenerator
-{
- ///
- /// Generic implementation of sequential serial number generation based on Base-N conversion.
- ///
- public class SerialNumberFormat
- {
- private readonly BigInteger _maxDecodedAllowedSerialNumber;
- private readonly BigInteger _maxDecodedSerialNumber;
- private readonly BigInteger _maxVisualDecodedSerialNumber;
-
- private static SerialNumberFormat _base10Format;
- private static SerialNumberFormat _base26Format;
- private static SerialNumberFormat _base36Format;
-
- ///
- /// Default Base10 .
- ///
- public static SerialNumberFormat Base10 => _base10Format ?? (_base10Format = new SerialNumberFormat("0123456789"));
-
- ///
- /// Default Base26 .
- ///
- public static SerialNumberFormat Base26 => _base26Format ?? (_base26Format = new SerialNumberFormat("ABCDEFGHIJKLMNOPQRSTUVWXYZ"));
-
- ///
- /// Default Base36 .
- ///
- public static SerialNumberFormat Base36 => _base36Format ?? (_base36Format = new SerialNumberFormat("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"));
-
- ///
- /// The serial number format.
- ///
- public string Value { get; }
-
- public char[] ValueChars => Value.ToCharArray();
-
- ///
- /// The length of the given serial number format.
- ///
- public int Length => Value.Length;
-
- // TODO: What number is BMM?
- ///
- /// The visual padded length of a serial number.
- /// examples for number 1000 with a visual length of 3, 4 and 5:
- /// 3: BMM = 1000
- /// 4: ABMM
- /// 5: AABMM
- ///
- public byte VisualPaddingLength { get; }
-
- ///
- /// Initializes a new serial number format class typically for a custom format or different base combination.
- /// First character in a format is used as the padded/filler character. E.g. 0 or A.
- /// Example:
- /// Base10 => new SerialNumberFormat("0123456789");
- ///
- /// Base value format. See summary for example.
- public SerialNumberFormat(string baseValueFormat)
- : this(baseValueFormat, 5)
- {
- }
-
- ///
- /// Initializes a new serial number format class typically for a custom format or different base combination.
- /// First character in a format is used as the padded/filler character. E.g. 0 or A.
- /// Example:
- /// Base10 => new SerialNumberFormat("0123456789");
- ///
- /// Base value format. See summary for example.
- /// Visual output of encoded string with a new length.
- /// If is a low number or is overflown when used to raise the power of the serial number format's length.
- public SerialNumberFormat(string baseValueFormat, byte visualPaddingLength)
- {
- if (baseValueFormat.Length <= 3)
- {
- throw new ArgumentException("Base value format must be a length of greater than 3 in order to give a substantial amount of sequentially generated serial numbers.", nameof(baseValueFormat));
- }
-
- if (baseValueFormat.Distinct().Count() != baseValueFormat.Length)
- {
- throw new ArgumentException("Base value format must contain unique characters.", nameof(baseValueFormat));
- }
-
- if (visualPaddingLength <= 1 || visualPaddingLength > baseValueFormat.Length)
- {
- throw new ArgumentOutOfRangeException(nameof(visualPaddingLength), "Visual padding length must be greater than 1 and less than base format length.");
- }
-
- _maxDecodedAllowedSerialNumber = BigInteger.Pow(40, 40); // Hard cap for memory purposes
- _maxDecodedSerialNumber = BigInteger.Pow(baseValueFormat.Length, baseValueFormat.Length);
- _maxVisualDecodedSerialNumber = BigInteger.Pow(baseValueFormat.Length, visualPaddingLength);
-
- if (_maxDecodedSerialNumber >= _maxDecodedAllowedSerialNumber)
- {
- throw new ArgumentOutOfRangeException(nameof(visualPaddingLength), "The max possible decoded serial number overflows the formats hard cap. Try shortening the base value format in size.");
- }
-
- Value = baseValueFormat;
- VisualPaddingLength = visualPaddingLength;
- }
-
- ///
- /// Encode the given number into a selected format's string.
- ///
- /// The value to encode.
- /// Overrides the visual output of encoded string with a new length.
- /// value of encoded integer into selected format's string.
- /// input is less than zero.
- public string Encode(int input, byte? visualLengthOverride = null)
- {
- return Encode(new BigInteger(input), visualLengthOverride);
- }
-
- ///
- /// Encode the given number into a selected format's string.
- ///
- /// The value to encode.
- /// Overrides the visual output of encoded string with a new length.
- /// value of encoded integer into selected format's string.
- /// input is less than zero.
- public string Encode(long input, byte? visualLengthOverride = null)
- {
- return Encode(new BigInteger(input), visualLengthOverride);
- }
-
- ///
- /// Encode the given number into a selected format's string.
- ///
- /// The value to encode.
- /// Overrides the visual output of encoded string with a new length.
- /// value of encoded integer into selected format's string.
- /// input is less than zero.
- public string Encode(BigInteger input, byte? visualLengthOverride = null)
- {
- if (input < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(input), input, "input cannot be negative.");
- }
-
- var visualLength = visualLengthOverride ?? VisualPaddingLength;
-
- var characterStack = new Stack();
-
- while (input != 0)
- {
- characterStack.Push(ValueChars[(int)input % Length]);
- input /= Length;
- }
-
- return new string(characterStack.ToArray()).PadLeft(visualLength, Value[0]);
- }
-
- ///
- /// Decode the selected formats encoded string into an integer.
- ///
- /// The value to decode.
- ///
- /// value of decoded selected format's string.
- ///
- public int Decode(string input)
- {
- var number = 0;
-
- foreach (var c in input)
- {
- number = number * Length + Value.IndexOf(c);
- }
-
- return number;
- }
-
- ///
- /// Main method to generate multiple serial numbers.
- ///
- /// Overrides the visual output of encoded string with a new length.
- /// An IEnumerable<string> of serial numbers.
- public IEnumerable Generate(byte? visualLengthOverride = null)
- {
- for (long idx = 0; idx < _maxDecodedSerialNumber; idx++)
- {
- yield return Encode(idx, visualLengthOverride);
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/SerialNumberGenerator.csproj b/src/SerialNumberGenerator.csproj
deleted file mode 100644
index eb7e066..0000000
--- a/src/SerialNumberGenerator.csproj
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
- netstandard2.0
-
-
-
\ No newline at end of file
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecConstructionTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecConstructionTests.cs
new file mode 100644
index 0000000..0b25d7d
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecConstructionTests.cs
@@ -0,0 +1,55 @@
+using System;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecConstructionTests
+{
+ [Fact]
+ public void Constructor_StoresAlphabetAndProperties()
+ {
+ var codec = new RadixCodec("02357ABC");
+ Assert.Equal("02357ABC", codec.Alphabet);
+ Assert.Equal(8, codec.Radix);
+ Assert.Equal(1, codec.MinLength);
+ Assert.Equal('0', codec.PadChar);
+ }
+
+ [Fact]
+ public void Constructor_TooFewCharacters_Throws()
+ => Assert.Throws(() => new RadixCodec("A"));
+
+ [Fact]
+ public void Constructor_DuplicateCharacters_Throws()
+ => Assert.Throws(() => new RadixCodec("AABC"));
+
+ [Fact]
+ public void Constructor_MinLengthBelowOne_Throws()
+ => Assert.Throws(() => new RadixCodec("0123", 0));
+
+ [Fact]
+ public void Constructor_NullAlphabet_Throws()
+ => Assert.Throws(() => new RadixCodec(null!));
+
+ [Fact]
+ public void Constructor_RadixTwo_IsAllowed()
+ => Assert.Equal(2, new RadixCodec("01").Radix);
+
+ [Theory]
+ [InlineData("Base10", "0123456789", 10, '0')]
+ [InlineData("Base26", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 26, 'A')]
+ [InlineData("Base36", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 36, '0')]
+ public void Presets_HaveExpectedShape(string name, string alphabet, int radix, char pad)
+ {
+ var codec = name switch
+ {
+ "Base10" => RadixCodec.Base10,
+ "Base26" => RadixCodec.Base26,
+ _ => RadixCodec.Base36,
+ };
+ Assert.Equal(alphabet, codec.Alphabet);
+ Assert.Equal(radix, codec.Radix);
+ Assert.Equal(5, codec.MinLength);
+ Assert.Equal(pad, codec.PadChar);
+ }
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecDecodeTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecDecodeTests.cs
new file mode 100644
index 0000000..44b264f
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecDecodeTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Numerics;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecDecodeTests
+{
+ [Fact]
+ public void Decode_Base26_MatchesKnownValue()
+ => Assert.Equal((BigInteger)1001, RadixCodec.Base26.DecodeBigInteger("AABMN"));
+
+ [Fact]
+ public void EncodeDecode_RoundTrips_PastInt32Max()
+ {
+ BigInteger value = (BigInteger)int.MaxValue + 123456;
+ var codec = RadixCodec.Base36;
+ Assert.Equal(value, codec.DecodeBigInteger(codec.Encode(value)));
+ }
+
+ [Fact]
+ public void EncodeDecode_RoundTrips_PastInt64Max()
+ {
+ BigInteger value = (BigInteger)ulong.MaxValue * 7;
+ var codec = RadixCodec.Base36;
+ Assert.Equal(value, codec.DecodeBigInteger(codec.Encode(value)));
+ }
+
+ [Fact]
+ public void Decode_UnknownCharacter_Throws()
+ => Assert.Throws(() => RadixCodec.Base10.DecodeBigInteger("12X45"));
+
+ [Fact]
+ public void Decode_Null_Throws()
+ => Assert.Throws(() => RadixCodec.Base10.DecodeBigInteger(null!));
+
+ [Fact]
+ public void Decode_Empty_Throws()
+ => Assert.Throws(() => RadixCodec.Base10.DecodeBigInteger(""));
+
+ [Fact]
+ public void Decode_RoundTrips_AcrossRange()
+ {
+ for (int i = 0; i < 1000; i++)
+ Assert.Equal((BigInteger)i, RadixCodec.Base26.DecodeBigInteger(RadixCodec.Base26.Encode(i)));
+ }
+
+ [Fact]
+ public void TryDecode_Valid_ReturnsTrue()
+ {
+ Assert.True(RadixCodec.Base26.TryDecodeBigInteger("AABMN", out var value));
+ Assert.Equal((BigInteger)1001, value);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("12X45")]
+ public void TryDecode_Invalid_ReturnsFalse(string? code)
+ {
+ Assert.False(RadixCodec.Base10.TryDecodeBigInteger(code, out var value));
+ Assert.Equal(BigInteger.Zero, value);
+ }
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecEncodeTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecEncodeTests.cs
new file mode 100644
index 0000000..41db597
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecEncodeTests.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Numerics;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecEncodeTests
+{
+ [Fact]
+ public void Encode_Base26_MatchesKnownValue()
+ => Assert.Equal("AABMN", RadixCodec.Base26.Encode(1001));
+
+ [Fact]
+ public void Encode_Base10_PadsToMinLength()
+ => Assert.Equal("00042", RadixCodec.Base10.Encode(42));
+
+ [Fact]
+ public void Encode_Zero_IsAllPad()
+ => Assert.Equal("00000", RadixCodec.Base10.Encode(0));
+
+ [Fact]
+ public void Encode_Negative_Throws()
+ => Assert.Throws(() => RadixCodec.Base10.Encode(-1));
+
+ [Fact]
+ public void Encode_NaturalLongerThanMinLength_NotTruncated()
+ => Assert.Equal("1234567", RadixCodec.Base10.Encode(1234567));
+
+ [Fact]
+ public void Encode_MinLengthOverride_PadsWhenShorter()
+ {
+ var codec = new RadixCodec("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 2);
+ Assert.Equal("BMN", codec.Encode(1001)); // natural length 3 > minLength 2
+ Assert.Equal("AAAAABMN", codec.Encode(1001, 8)); // padded to width 8
+ }
+
+ [Fact]
+ public void Encode_BigInteger_AboveInt32Max_DoesNotThrow()
+ {
+ BigInteger big = (BigInteger)long.MaxValue + 1000;
+ var code = RadixCodec.Base36.Encode(big);
+ Assert.False(string.IsNullOrEmpty(code));
+ }
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecEqualityTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecEqualityTests.cs
new file mode 100644
index 0000000..466c846
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecEqualityTests.cs
@@ -0,0 +1,27 @@
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecEqualityTests
+{
+ [Fact]
+ public void Equals_SameAlphabetAndMinLength_AreEqual()
+ {
+ var a = new RadixCodec("0123", 3);
+ var b = new RadixCodec("0123", 3);
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equals_DifferentMinLength_AreNotEqual()
+ => Assert.NotEqual(new RadixCodec("0123", 2), new RadixCodec("0123", 3));
+
+ [Fact]
+ public void Equals_DifferentAlphabet_AreNotEqual()
+ => Assert.NotEqual(new RadixCodec("0123"), new RadixCodec("3210"));
+
+ [Fact]
+ public void Equals_Null_IsFalse()
+ => Assert.False(new RadixCodec("0123").Equals(null));
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecOdometerTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecOdometerTests.cs
new file mode 100644
index 0000000..afc4821
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecOdometerTests.cs
@@ -0,0 +1,65 @@
+using System;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecOdometerTests
+{
+ [Fact]
+ public void TrySeed_PadsToMinLength_RightAligned()
+ {
+ Span buf = stackalloc char[16];
+ Assert.True(RadixCodec.Base26.TrySeed(buf, 1001, out int length));
+ Assert.Equal(RadixCodec.Base26.Encode(1001), new string(buf.Slice(buf.Length - length, length)));
+ }
+
+ [Fact]
+ public void TrySeed_Zero_Pads()
+ {
+ Span buf = stackalloc char[8];
+ Assert.True(RadixCodec.Base10.TrySeed(buf, 0, out int length));
+ Assert.Equal("00000", new string(buf.Slice(buf.Length - length, length)));
+ }
+
+ [Fact]
+ public void TrySeed_TooSmall_ReturnsFalse()
+ {
+ Span buf = stackalloc char[2];
+ Assert.False(RadixCodec.Base10.TrySeed(buf, 100000, out _)); // MinLength 5 > 2
+ }
+
+ [Fact]
+ public void TryIncrement_MatchesEncode_PastInt32()
+ {
+ Span buf = stackalloc char[16];
+ var codec = RadixCodec.Base36;
+ long start = 10_000_000_000L;
+ Assert.True(codec.TrySeed(buf, start, out int length));
+ for (long v = start; v < start + 50; v++)
+ {
+ Assert.Equal(codec.Encode(v), new string(buf.Slice(buf.Length - length, length)));
+ Assert.True(codec.TryIncrement(buf, ref length));
+ }
+ }
+
+ [Fact]
+ public void TryIncrement_GrowsWidth()
+ {
+ Span buf = stackalloc char[8];
+ var codec = new RadixCodec("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); // minLength 1
+ Assert.True(codec.TrySeed(buf, 25, out int length)); // "Z"
+ Assert.Equal("Z", new string(buf.Slice(buf.Length - length, length)));
+ Assert.True(codec.TryIncrement(buf, ref length)); // 26 -> "BA"
+ Assert.Equal("BA", new string(buf.Slice(buf.Length - length, length)));
+ Assert.Equal(2, length);
+ }
+
+ [Fact]
+ public void TryIncrement_BufferFull_ReturnsFalse()
+ {
+ Span buf = stackalloc char[2];
+ var codec = new RadixCodec("01"); // radix 2, minLength 1
+ Assert.True(codec.TrySeed(buf, 3, out int length)); // "11"
+ Assert.False(codec.TryIncrement(buf, ref length)); // needs "100"
+ }
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecTryEncodeTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecTryEncodeTests.cs
new file mode 100644
index 0000000..3026a79
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecTryEncodeTests.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Numerics;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecTryEncodeTests
+{
+ [Fact]
+ public void TryEncode_WritesPaddedCode()
+ {
+ Span buffer = stackalloc char[16];
+ Assert.True(RadixCodec.Base26.TryEncode(1001, buffer, out int written));
+ Assert.Equal("AABMN", new string(buffer[..written]));
+ }
+
+ [Fact]
+ public void TryEncode_Zero_WritesPad()
+ {
+ Span buffer = stackalloc char[16];
+ Assert.True(RadixCodec.Base10.TryEncode(0, buffer, out int written));
+ Assert.Equal("00000", new string(buffer[..written]));
+ }
+
+ [Fact]
+ public void TryEncode_BufferTooSmall_ReturnsFalse()
+ {
+ Span buffer = stackalloc char[2];
+ Assert.False(RadixCodec.Base26.TryEncode(1001, buffer, out int written));
+ Assert.Equal(0, written);
+ }
+
+ [Fact]
+ public void TryEncode_MatchesEncode()
+ {
+ Span buffer = stackalloc char[32];
+ for (int i = 0; i < 500; i++)
+ {
+ Assert.True(RadixCodec.Base36.TryEncode(i, buffer, out int written));
+ Assert.Equal(RadixCodec.Base36.Encode(i), new string(buffer[..written]));
+ }
+ }
+
+ [Fact]
+ public void TryEncode_LongOverload_MatchesBigInteger()
+ {
+ Span typed = stackalloc char[32];
+ Span big = stackalloc char[32];
+ long value = 10_000_000_000L;
+
+ Assert.True(RadixCodec.Base36.TryEncode(value, typed, out int typedWritten));
+ Assert.True(RadixCodec.Base36.TryEncode((BigInteger)value, big, out int bigWritten));
+ Assert.Equal(new string(big[..bigWritten]), new string(typed[..typedWritten]));
+ }
+
+ [Fact]
+ public void TryEncode_IntOverload_MatchesBigInteger()
+ {
+ Span typed = stackalloc char[32];
+ Span big = stackalloc char[32];
+ int value = 1_000_000;
+
+ Assert.True(RadixCodec.Base36.TryEncode(value, typed, out int typedWritten));
+ Assert.True(RadixCodec.Base36.TryEncode((BigInteger)value, big, out int bigWritten));
+ Assert.Equal(new string(big[..bigWritten]), new string(typed[..typedWritten]));
+ }
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixCodecTypedDecodeTests.cs b/test/SequentialRadixCodec.Tests/RadixCodecTypedDecodeTests.cs
new file mode 100644
index 0000000..cfefd4b
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixCodecTypedDecodeTests.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Numerics;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixCodecTypedDecodeTests
+{
+ [Fact]
+ public void DecodeInt64_RoundTrips()
+ {
+ long value = 10_000_000_000L; // 10 billion - past int, well within long
+ var codec = RadixCodec.Base36;
+ Assert.Equal(value, codec.DecodeInt64(codec.Encode(value)));
+ }
+
+ [Fact]
+ public void DecodeInt64_AtMaxValue_RoundTrips()
+ {
+ var codec = RadixCodec.Base36;
+ Assert.Equal(long.MaxValue, codec.DecodeInt64(codec.Encode(long.MaxValue)));
+ }
+
+ [Fact]
+ public void DecodeInt64_OverMaxValue_Throws()
+ {
+ var codec = RadixCodec.Base36;
+ string code = codec.Encode((BigInteger)long.MaxValue + 1);
+ Assert.Throws(() => codec.DecodeInt64(code));
+ }
+
+ [Fact]
+ public void TryDecodeInt64_Valid_ReturnsTrue()
+ {
+ var codec = RadixCodec.Base36;
+ Assert.True(codec.TryDecodeInt64(codec.Encode(10_000_000_000L), out long value));
+ Assert.Equal(10_000_000_000L, value);
+ }
+
+ [Fact]
+ public void TryDecodeInt64_OverMaxValue_ReturnsFalse()
+ {
+ var codec = RadixCodec.Base36;
+ string code = codec.Encode((BigInteger)long.MaxValue + 1);
+ Assert.False(codec.TryDecodeInt64(code, out long value));
+ Assert.Equal(0L, value);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("12X45")]
+ public void TryDecodeInt64_Invalid_ReturnsFalse(string? code)
+ {
+ Assert.False(RadixCodec.Base10.TryDecodeInt64(code, out long value));
+ Assert.Equal(0L, value);
+ }
+
+ [Fact]
+ public void DecodeInt32_RoundTrips()
+ {
+ int value = 1_000_000;
+ var codec = RadixCodec.Base36;
+ Assert.Equal(value, codec.DecodeInt32(codec.Encode(value)));
+ }
+
+ [Fact]
+ public void DecodeInt32_AtMaxValue_RoundTrips()
+ {
+ var codec = RadixCodec.Base36;
+ Assert.Equal(int.MaxValue, codec.DecodeInt32(codec.Encode(int.MaxValue)));
+ }
+
+ [Fact]
+ public void DecodeInt32_OverMaxValue_Throws()
+ {
+ var codec = RadixCodec.Base36;
+ string code = codec.Encode((BigInteger)int.MaxValue + 1);
+ Assert.Throws(() => codec.DecodeInt32(code));
+ }
+
+ [Fact]
+ public void TryDecodeInt32_Valid_ReturnsTrue()
+ {
+ var codec = RadixCodec.Base36;
+ Assert.True(codec.TryDecodeInt32(codec.Encode(1_000_000), out int value));
+ Assert.Equal(1_000_000, value);
+ }
+
+ [Fact]
+ public void TryDecodeInt32_OverMaxValue_ReturnsFalse()
+ {
+ var codec = RadixCodec.Base36;
+ string code = codec.Encode((BigInteger)int.MaxValue + 1);
+ Assert.False(codec.TryDecodeInt32(code, out int value));
+ Assert.Equal(0, value);
+ }
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixSequenceEnumeratorTests.cs b/test/SequentialRadixCodec.Tests/RadixSequenceEnumeratorTests.cs
new file mode 100644
index 0000000..4193ec7
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixSequenceEnumeratorTests.cs
@@ -0,0 +1,139 @@
+using System;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixSequenceEnumeratorTests
+{
+ [Fact]
+ public void EnumerateInto_MatchesEncode()
+ {
+ Span buf = stackalloc char[16];
+ var codec = RadixCodec.Base26;
+ const long start = 1001;
+ long k = 0;
+ foreach (var code in RadixSequence.EnumerateInto(codec, buf, start, count: 100))
+ {
+ Assert.Equal(codec.Encode(start + k), new string(code));
+ k++;
+ }
+ Assert.Equal(100, k);
+ }
+
+ [Fact]
+ public void EnumerateInto_CountZero_Empty()
+ {
+ Span buf = stackalloc char[16];
+ int n = 0;
+ foreach (var _ in RadixSequence.EnumerateInto(RadixCodec.Base10, buf, 5, count: 0))
+ n++;
+ Assert.Equal(0, n);
+ }
+
+ [Fact]
+ public void EnumerateInto_BoundedBufferTooSmall_Throws()
+ {
+ Assert.Throws(() =>
+ {
+ Span buf = stackalloc char[3]; // Base10 MinLength 5 > 3
+ foreach (var _ in RadixSequence.EnumerateInto(RadixCodec.Base10, buf, 0, count: 10)) { }
+ });
+ }
+
+ [Fact]
+ public void EnumerateInto_UnboundedBufferLimit_StopsGracefully()
+ {
+ Span buf = stackalloc char[2];
+ var codec = new RadixCodec("01"); // radix 2, minLength 1
+ int n = 0;
+ foreach (var _ in RadixSequence.EnumerateInto(codec, buf, start: 0)) // unbounded
+ {
+ n++;
+ if (n > 100) break; // safety net
+ }
+ Assert.Equal(4, n); // "0","1","10","11"; next ("100") needs 3 chars
+ }
+
+ [Fact]
+ public void EnumerateInto_NullCodec_Throws()
+ => Assert.Throws(() =>
+ {
+ Span buf = stackalloc char[8];
+ foreach (var _ in RadixSequence.EnumerateInto(null!, buf, 0)) { }
+ });
+
+ [Fact]
+ public void EnumerateInto_NegativeStart_Throws()
+ => Assert.Throws(() =>
+ {
+ Span buf = stackalloc char[8];
+ foreach (var _ in RadixSequence.EnumerateInto(RadixCodec.Base10, buf, -1)) { }
+ });
+
+ [Fact]
+ public void EnumerateInto_DoesNotAllocate()
+ {
+ var codec = RadixCodec.Base36;
+ Sum(codec, 5); // warm up JIT
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ long sum = Sum(codec, 1000);
+ long after = GC.GetAllocatedBytesForCurrentThread();
+
+ Assert.True(sum > 0);
+ Assert.Equal(0, after - before);
+
+ static long Sum(RadixCodec codec, int count)
+ {
+ Span buf = stackalloc char[16];
+ long total = 0;
+ foreach (var code in RadixSequence.EnumerateInto(codec, buf, start: 10_000_000_000L, count: count))
+ total += code.Length;
+ return total;
+ }
+ }
+
+ [Fact]
+ public void Enumerate_Pooled_MatchesEncode()
+ {
+ var codec = RadixCodec.Base26;
+ const long start = 1001;
+ long k = 0;
+ foreach (var code in RadixSequence.Enumerate(codec, start, count: 100))
+ {
+ Assert.Equal(codec.Encode(start + k), new string(code));
+ k++;
+ }
+ Assert.Equal(100, k);
+ }
+
+ [Fact]
+ public void Enumerate_Pooled_Radix2_LargeStart()
+ {
+ var codec = new RadixCodec("01"); // wide codes for large longs
+ const long start = 10_000_000_000L;
+ long k = 0;
+ foreach (var code in RadixSequence.Enumerate(codec, start, count: 20))
+ {
+ Assert.Equal(codec.Encode(start + k), new string(code));
+ k++;
+ }
+ Assert.Equal(20, k);
+ }
+
+ [Fact]
+ public void Enumerate_Pooled_CountZero_Empty()
+ {
+ int n = 0;
+ foreach (var _ in RadixSequence.Enumerate(RadixCodec.Base10, 5, count: 0))
+ n++;
+ Assert.Equal(0, n);
+ }
+
+ [Fact]
+ public void Enumerate_Pooled_NegativeCount_Throws()
+ => Assert.Throws(() =>
+ {
+ foreach (var _ in RadixSequence.Enumerate(RadixCodec.Base10, 0, count: -1)) { }
+ });
+}
diff --git a/test/SequentialRadixCodec.Tests/RadixSequenceTests.cs b/test/SequentialRadixCodec.Tests/RadixSequenceTests.cs
new file mode 100644
index 0000000..bf659a4
--- /dev/null
+++ b/test/SequentialRadixCodec.Tests/RadixSequenceTests.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Linq;
+using Xunit;
+
+namespace SequentialRadixCodec.Tests;
+
+public class RadixSequenceTests
+{
+ [Fact]
+ public void From_StartsAtGivenIndex()
+ {
+ var first = RadixSequence.From(RadixCodec.Base10, start: 1000).First();
+ Assert.Equal("01000", first);
+ }
+
+ [Fact]
+ public void From_Unbounded_IsLazyAndTakeable()
+ {
+ var items = RadixSequence.From(RadixCodec.Base26).Skip(1001).Take(3).ToArray();
+ Assert.Equal(new[] { "AABMN", "AABMO", "AABMP" }, items);
+ }
+
+ [Fact]
+ public void From_WithCount_YieldsExactCount()
+ {
+ var items = RadixSequence.From(RadixCodec.Base10, start: 5, count: 3).ToArray();
+ Assert.Equal(new[] { "00005", "00006", "00007" }, items);
+ }
+
+ [Fact]
+ public void From_NegativeStart_ThrowsEagerly()
+ => Assert.Throws(() => RadixSequence.From(RadixCodec.Base10, start: -1));
+
+ [Fact]
+ public void From_NegativeCount_ThrowsEagerly()
+ => Assert.Throws(() => RadixSequence.From(RadixCodec.Base10, start: 0, count: -1));
+
+ [Fact]
+ public void From_NullCodec_ThrowsEagerly()
+ => Assert.Throws(() => RadixSequence.From(null!));
+}
diff --git a/test/SequentialSerialNumberGenerator.Test/SerialNumberGenerator.Test.csproj b/test/SequentialRadixCodec.Tests/SequentialRadixCodec.Tests.csproj
similarity index 65%
rename from test/SequentialSerialNumberGenerator.Test/SerialNumberGenerator.Test.csproj
rename to test/SequentialRadixCodec.Tests/SequentialRadixCodec.Tests.csproj
index 7dd20c9..7743e38 100644
--- a/test/SequentialSerialNumberGenerator.Test/SerialNumberGenerator.Test.csproj
+++ b/test/SequentialRadixCodec.Tests/SequentialRadixCodec.Tests.csproj
@@ -1,22 +1,21 @@
- netcoreapp2.2
-
+ net10.0
false
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers
-
+
-
+
\ No newline at end of file
diff --git a/test/SequentialSerialNumberGenerator.ConsoleTest/Program.cs b/test/SequentialSerialNumberGenerator.ConsoleTest/Program.cs
deleted file mode 100644
index 1e6b45b..0000000
--- a/test/SequentialSerialNumberGenerator.ConsoleTest/Program.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using System;
-using System.Linq;
-
-namespace SerialNumberGenerator.ConsoleTest
-{
- class Program
- {
- private static void Main()
- {
- // Pretend database lookup retrieved this value
- const string lastGeneratedSerialNumber = "AABMM"; // Base26 Test
-
- Console.WriteLine($"Showing Base26 usage{Environment.NewLine}------------------------------");
- Console.WriteLine($"Last generated serial number: {lastGeneratedSerialNumber}");
-
- // Decode the value into a integer
- int decodedValue = SerialNumberFormat.Base26.Decode(lastGeneratedSerialNumber);
-
- Console.WriteLine($"Decoded value: {decodedValue}");
- Console.WriteLine("Generating next 100 serial numbers method 1:");
-
- // Generate always starts at zero to base format max unless using LINQ Skip / Take to get a specified range of generation
- foreach (var serialNumber in SerialNumberFormat.Base26.Generate().Skip(decodedValue + 1).Take(100))
- {
- Console.Write($"{serialNumber} ");
- }
-
- //var serialNumberGenerator = new SerialNumberFormat("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
-
- //foreach (var serialNumber in serialNumberGenerator.Generate().Skip(decodedValue + 1).Take(100))
- //{
- // Console.Write($"{serialNumber} ");
- //}
-
- Console.ReadKey();
- Console.WriteLine();
- Console.WriteLine("Generating next 100 serial numbers method 2:");
-
- // Show a second way to generate values instead of using enumeration
- // This may be a more clever way of just needing to convert a single integer value to a display of the base encoding
- int nextTopValue = decodedValue + 100;
-
- while (decodedValue++ < nextTopValue)
- {
- Console.Write($"{SerialNumberFormat.Base26.Encode(decodedValue, 5)} ");
- }
-
- Console.WriteLine();
- Console.WriteLine($"Showing Base10 usage{Environment.NewLine}------------------------------");
-
- foreach (var serialNumber in SerialNumberFormat.Base10.Generate().Skip(1000 + 1).Take(100))
- {
- Console.Write($"{serialNumber} ");
- }
-
- Console.WriteLine();
- Console.WriteLine($"Showing custom made Base28 (added characters: '0' & '1') usage{Environment.NewLine}------------------------------");
-
- // Set the numbering system format
- var serialNumberFormat = new SerialNumberFormat("ABCDEFGHIJKLMNOPQRSTUVWXYZ01", 5);
-
- foreach (var serialNumber in serialNumberFormat.Generate().Skip(1000 + 1).Take(100))
- {
- Console.Write($"{serialNumber} ");
- }
-
- Console.WriteLine();
- }
- }
-}
\ No newline at end of file
diff --git a/test/SequentialSerialNumberGenerator.ConsoleTest/SerialNumberGenerator.ConsoleTest.csproj b/test/SequentialSerialNumberGenerator.ConsoleTest/SerialNumberGenerator.ConsoleTest.csproj
deleted file mode 100644
index a4eb50f..0000000
--- a/test/SequentialSerialNumberGenerator.ConsoleTest/SerialNumberGenerator.ConsoleTest.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- netcoreapp2.1
-
- Exe
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test/SequentialSerialNumberGenerator.Test/SerialNumberFormatTests.cs b/test/SequentialSerialNumberGenerator.Test/SerialNumberFormatTests.cs
deleted file mode 100644
index 94c32e2..0000000
--- a/test/SequentialSerialNumberGenerator.Test/SerialNumberFormatTests.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System;
-using Xunit;
-
-namespace SerialNumberGenerator.Test
-{
- public class SerialNumberFormatTests
- {
- [Theory]
- [InlineData("02357ABC")]
- public void CustomFormat_Setup_Valid(string format)
- {
- var customSerialNumberFormat = new SerialNumberFormat(format);
-
- Assert.Equal(format, customSerialNumberFormat.Value);
- Assert.Equal(format.Length, customSerialNumberFormat.Length);
- }
-
- [Fact]
- public void CustomFormat_Setup_ExceptionsThrown()
- {
- Assert.Throws(() => new SerialNumberFormat("ABC")); // Too small of a serial number format
- Assert.Throws(() => new SerialNumberFormat("ABCDEFGHIJKLMMNOPQRSTUVWXYZ")); // Duplicate characters
- Assert.Throws(() => new SerialNumberFormat("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$")); // 40 characters to the power of 40 equals hard cap exception
- Assert.Throws(() => new SerialNumberFormat("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 40)); // Visual padding length longer than format length
- }
-
- [Fact]
- public void Base10_EncodeAndDecode_Valid()
- {
- for (int idx = 0; idx < 1000; idx++)
- {
- var encodedValue = SerialNumberFormat.Base10.Encode(idx);
- var decodedValue = SerialNumberFormat.Base10.Decode(encodedValue);
-
- Assert.Equal(idx, decodedValue);
- }
- }
-
- [Fact]
- public void Base26_EncodeAndDecode_Valid()
- {
- for (int idx = 0; idx < 1000; idx++)
- {
- var encodedValue = SerialNumberFormat.Base26.Encode(idx);
- var decodedValue = SerialNumberFormat.Base26.Decode(encodedValue);
-
- Assert.Equal(idx, decodedValue);
- }
- }
-
- [Fact]
- public void CustomFormat_EncodeNegativeValue_ExceptionThrown()
- {
- var customSerialNumberFormat = new SerialNumberFormat("0123456789");
-
- Assert.Throws(() => customSerialNumberFormat.Encode(-52));
- }
-
- [Theory]
- [InlineData("Z0123456789", 10)]
- public void CustomFormat_PaddingEncodeAndDecode_Valid(string format, byte paddingLength)
- {
- // Arrange
- var customSerialNumberFormat = new SerialNumberFormat(format, paddingLength);
- var numberToEncode = 571039;
-
- // Act
- var encodedValue = customSerialNumberFormat.Encode(numberToEncode);
- var decodedValue = customSerialNumberFormat.Decode(encodedValue);
- var padding = encodedValue.Substring(0, 4);
-
- // Assert
- Assert.Equal("ZZZZ", padding);
- Assert.Equal(numberToEncode, decodedValue);
- }
- }
-}