From cea5bd944ea7a0564ee714a14d9d2dbdbd996753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Tue, 29 Sep 2026 14:27:57 +0300 Subject: [PATCH] ByteAether.Ulid.Cli introduced. --- README.md | 46 +++++ assets/logo_ulid_tool.png | Bin 0 -> 34895 bytes src/ByteAether.Ulid.slnx | 8 + src/Cli.Tests/Cli.Tests.csproj | 19 ++ src/Cli.Tests/CliTests.cs | 323 ++++++++++++++++++++++++++++++ src/Cli.Tests/GeneratorTests.cs | 64 ++++++ src/Cli.Tests/InputParserTests.cs | 98 +++++++++ src/Cli.Tests/InspectorTests.cs | 131 ++++++++++++ src/Cli/Cli.Impl.csproj | 43 ++++ src/Cli/Cli.cs | 239 ++++++++++++++++++++++ src/Cli/GenerateOptions.cs | 52 +++++ src/Cli/Generator.cs | 53 +++++ src/Cli/Hex.cs | 40 ++++ src/Cli/InputParser.cs | 199 ++++++++++++++++++ src/Cli/InspectionResult.cs | 68 +++++++ src/Cli/Inspector.cs | 88 ++++++++ src/Cli/PACKAGE.md | 251 +++++++++++++++++++++++ src/Cli/Program.cs | 3 + src/Directory.Packages.props | 2 + 19 files changed, 1727 insertions(+) create mode 100644 assets/logo_ulid_tool.png create mode 100644 src/Cli.Tests/Cli.Tests.csproj create mode 100644 src/Cli.Tests/CliTests.cs create mode 100644 src/Cli.Tests/GeneratorTests.cs create mode 100644 src/Cli.Tests/InputParserTests.cs create mode 100644 src/Cli.Tests/InspectorTests.cs create mode 100644 src/Cli/Cli.Impl.csproj create mode 100644 src/Cli/Cli.cs create mode 100644 src/Cli/GenerateOptions.cs create mode 100644 src/Cli/Generator.cs create mode 100644 src/Cli/Hex.cs create mode 100644 src/Cli/InputParser.cs create mode 100644 src/Cli/InspectionResult.cs create mode 100644 src/Cli/Inspector.cs create mode 100644 src/Cli/PACKAGE.md create mode 100644 src/Cli/Program.cs diff --git a/README.md b/README.md index 9d56be0..f0ab4c6 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ This library explicitly **multi-targets** each runtime version listed below, ena `ByteAether.Ulid.linq2db` * 📦 **[Dapper](#dapper-integration--byteaetheruliddapper)** `ByteAether.Ulid.Dapper` +* 📦 **[CLI & .NET Tool](#net-tool--cli-integration--byteaetherulidcli)** + `ByteAether.Ulid.Cli` These features collectively make **ByteAether.Ulid** a robust and efficient choice for managing unique identifiers in your .NET applications. @@ -559,6 +561,50 @@ MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions )); ``` +### [.NET Tool & CLI](https://www.nuget.org/packages/ByteAether.Ulid.Cli/) Integration – ByteAether.Ulid.Cli + +[](https://www.nuget.org/packages/ByteAether.Ulid.Cli/) + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.Cli?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.Cli/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.Cli?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.Cli/) +![.NET 6.0+](https://img.shields.io/badge/.NET-6.0+-brightgreen) + +An official CLI tool and companion utility for `ByteAether.Ulid`, providing command-line generation and inspection of Universally Unique Lexicographically Sortable Identifiers (ULIDs). Optimized for scripting, pipelines, terminal workflows, and tool interop. + +To install globally as a .NET Tool: + +```sh +dotnet tool install -g ByteAether.Ulid.Cli +``` + +Or install locally in your repository/project: + +```sh +dotnet new tool-manifest # if not already present +dotnet tool install ByteAether.Ulid.Cli +``` + +#### Usage + +When installed globally, run `ulid`. When installed locally, prefix commands with `dotnet ulid`. + +```sh +# Generate a single ULID +ulid + +# Generate 5 ULIDs formatted as GUIDs +ulid -c 5 -f guid + +# Inspect a ULID +ulid 01AN4Z07BY79KA1307SR9X4MV3 + +# Inspect from stdin and output as JSON +echo "01AN4Z07BY79KA1307SR9X4MV3" | ulid --json +``` + +More details in the package's [PACKAGE.md](./src/Cli/PACKAGE.md) file. + ## 📊 Benchmarking Benchmarking was performed using [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) to demonstrate the performance and efficiency of this ULID implementation. Comparisons include [NetUlid](https://github.com/ultimicro/netulid) 2.1.0, [Ulid](https://github.com/Cysharp/Ulid) 1.4.1, [NUlid](https://github.com/RobThree/NUlid) 1.7.3, and `Guid` for overlapping functionalities like creation, parsing, and byte conversions. diff --git a/assets/logo_ulid_tool.png b/assets/logo_ulid_tool.png new file mode 100644 index 0000000000000000000000000000000000000000..dc5877a7add5002de69afd71d883dd0558bf28c0 GIT binary patch literal 34895 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 R$OHub(bY22tkSTL`9CyOfu{ff literal 0 HcmV?d00001 diff --git a/src/ByteAether.Ulid.slnx b/src/ByteAether.Ulid.slnx index 692d516..adda073 100644 --- a/src/ByteAether.Ulid.slnx +++ b/src/ByteAether.Ulid.slnx @@ -20,6 +20,13 @@ + + + + + + + @@ -47,6 +54,7 @@ + diff --git a/src/Cli.Tests/Cli.Tests.csproj b/src/Cli.Tests/Cli.Tests.csproj new file mode 100644 index 0000000..e67d69e --- /dev/null +++ b/src/Cli.Tests/Cli.Tests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + + true + true + + + + ByteAether.Ulid.Cli.Tests + + + + + + + + diff --git a/src/Cli.Tests/CliTests.cs b/src/Cli.Tests/CliTests.cs new file mode 100644 index 0000000..236787c --- /dev/null +++ b/src/Cli.Tests/CliTests.cs @@ -0,0 +1,323 @@ +namespace ByteAether.Ulid.Cli.Tests; + +public class CliTests +{ + [Fact] + public void Run_NoArgs_GeneratesSingleUlid() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run([], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + var output = stdout.ToString().Trim(); + Assert.Equal(26, output.Length); + Assert.True(Ulid.TryParse(output, null, out _)); + } + + [Fact] + public void Run_GenerateCount3_Generates3Lines() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["-c", "3"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + var lines = stdout.ToString().Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + foreach (var line in lines) + { + Assert.True(Ulid.TryParse(line.Trim(), null, out _)); + } + } + + [Fact] + public void Run_GenerateWithExactBytes_ProducesExpectedUlid() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run([ + "--time", "01A0ED093A00", + "--random", "5D6762477ACBF9130587" + ], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + Assert.Equal("01M3PGJEG0BNKP4HVTSFWH61C7", stdout.ToString().Trim()); + } + + [Fact] + public void Run_GenerateHexFormat_Produces32CharHex() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run([ + "--time", "01A0ED093A00", + "--random", "5D6762477ACBF9130587", + "-f", "hex" + ], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + Assert.Equal("01A0ED093A005D6762477ACBF9130587", stdout.ToString().Trim()); + } + + [Fact] + public void Run_GenerateUnknownFormat_ReturnsErrorInsteadOfDefaulting() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["--format", "binary"], stdout, stderr, stdin); + + Assert.Equal(1, exitCode); + Assert.Empty(stdout.ToString()); + Assert.Contains("Unknown format 'binary'", stderr.ToString()); + } + + [Fact] + public void Run_GenerateJsonBatch_StreamsValidJsonArray() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["--count", "3", "--json"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(stdout.ToString()); + Assert.Equal(3, document.RootElement.GetArrayLength()); + foreach (var item in document.RootElement.EnumerateArray()) + { + Assert.True(Ulid.TryParse(item.GetString(), null, out _)); + } + } + + [Fact] + public void Run_Inspect_OutputsExpectedGreppableLines() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + var output = stdout.ToString(); + Assert.Contains("Ulid: 01M3PGJEG0BNKP4HVTSFWH61C7", output); + Assert.Contains("Hex: 01A0ED093A005D6762477ACBF9130587", output); + Assert.Contains("Time: 01M3PGJEG0", output); + Assert.Contains("Time (Hex): 01A0ED093A00", output); + Assert.Contains("Random: BNKP4HVTSFWH61C7", output); + Assert.Contains("Random (Hex): 5D6762477ACBF9130587", output); + } + + [Fact] + public void Run_InspectPartTimeHex_OutputsOnlyTimeHex() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7", "-p", "timeHex"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + Assert.Equal("01A0ED093A00", stdout.ToString().Trim()); + } + + [Fact] + public void Run_InspectPartRandomCrockford_OutputsOnlyRandomC32() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7", "-p", "random"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + Assert.Equal("BNKP4HVTSFWH61C7", stdout.ToString().Trim()); + } + + [Theory] + [InlineData("ulid", "01M3PGJEG0BNKP4HVTSFWH61C7")] + [InlineData("hex", "01A0ED093A005D6762477ACBF9130587")] + [InlineData("time", "01M3PGJEG0")] + [InlineData("timeHex", "01A0ED093A00")] + [InlineData("timeIso", "2026-09-29T12:00:00.000Z")] + [InlineData("timestamp", "1790683200000")] + [InlineData("random", "BNKP4HVTSFWH61C7")] + [InlineData("randomHex", "5D6762477ACBF9130587")] + public void Run_InspectPart_OutputsDocumentedValue(string part, string expected) + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7", "--part", part], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + Assert.Equal(expected, stdout.ToString().Trim()); + } + + [Fact] + public void Run_InspectPartJson_OutputsJsonString() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7", "--part", "timeHex", "--json"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(stdout.ToString()); + Assert.Equal(System.Text.Json.JsonValueKind.String, document.RootElement.ValueKind); + Assert.Equal("01A0ED093A00", document.RootElement.GetString()); + } + + [Fact] + public void Run_InspectMultiplePartJsonInputs_OutputsJsonArray() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader("01M3PGJEG0BNKP4HVTSFWH61C7\n01M3PGJEG0BNKP4HVTSFWH61C7\n"); + + var exitCode = Cli.Run(["--part", "random", "--json"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(stdout.ToString()); + Assert.Equal(System.Text.Json.JsonValueKind.Array, document.RootElement.ValueKind); + Assert.Equal(2, document.RootElement.GetArrayLength()); + Assert.All(document.RootElement.EnumerateArray(), item => Assert.Equal("BNKP4HVTSFWH61C7", item.GetString())); + } + + [Fact] + public void Run_InspectPartUndocumentedName_ReturnsError() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7", "--part", "time-c32"], stdout, stderr, stdin); + + Assert.Equal(1, exitCode); + Assert.Empty(stdout.ToString()); + Assert.Contains("Available parts: ulid, hex, time, timeHex, timeIso, timestamp, random, randomHex", stderr.ToString()); + } + + [Fact] + public void Run_InspectMultipleJsonInputs_OutputsValidJsonArray() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader("01M3PGJEG0BNKP4HVTSFWH61C7\n01M3PGJEG0BNKP4HVTSFWH61C7\n"); + + var exitCode = Cli.Run(["--json"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + using var document = System.Text.Json.JsonDocument.Parse(stdout.ToString()); + Assert.Equal(2, document.RootElement.GetArrayLength()); + } + + [Fact] + public void Run_InspectInvalidLaterJsonInput_DoesNotWritePartialJson() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader("01M3PGJEG0BNKP4HVTSFWH61C7\ninvalid\n"); + + var exitCode = Cli.Run(["--json"], stdout, stderr, stdin); + + Assert.Equal(1, exitCode); + Assert.Empty(stdout.ToString()); + Assert.Contains("not a valid ULID", stderr.ToString()); + } + + [Fact] + public void Run_InspectInvalidLaterTextInput_DoesNotWritePartialOutput() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader("01M3PGJEG0BNKP4HVTSFWH61C7\ninvalid\n"); + + var exitCode = Cli.Run([], stdout, stderr, stdin); + + Assert.Equal(1, exitCode); + Assert.Empty(stdout.ToString()); + Assert.Contains("not a valid ULID", stderr.ToString()); + } + + [Fact] + public void Run_InspectJson_OutputsValidJson() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["01M3PGJEG0BNKP4HVTSFWH61C7", "--json"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + var json = stdout.ToString().Trim(); + Assert.StartsWith("{", json); + Assert.Contains("\"timeHex\": \"01A0ED093A00\"", json); + } + + [Fact] + public void Run_InspectInvalidUlid_ReturnsExitCode1() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["invalid_id"], stdout, stderr, stdin); + + Assert.Equal(1, exitCode); + Assert.Contains("not a valid ULID", stderr.ToString()); + } + + [Fact] + public void Run_Help_ReturnsExitCode0AndPrintsHelp() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["--help"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + var output = stdout.ToString(); + Assert.Contains("generate", output); + Assert.Contains("inspect", output); + } + + [Fact] + public void Run_Version_ReturnsExitCode0AndPrintsVersion() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["--version"], stdout, stderr, stdin); + + Assert.Equal(0, exitCode); + Assert.NotEqual(string.Empty, stdout.ToString().Trim()); + } + + [Fact] + public void Run_UnknownArgument_ReturnsNonZeroExitCode() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var stdin = new StringReader(""); + + var exitCode = Cli.Run(["--unknown-flag-test"], stdout, stderr, stdin); + + Assert.NotEqual(0, exitCode); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/GeneratorTests.cs b/src/Cli.Tests/GeneratorTests.cs new file mode 100644 index 0000000..6de30e4 --- /dev/null +++ b/src/Cli.Tests/GeneratorTests.cs @@ -0,0 +1,64 @@ +namespace ByteAether.Ulid.Cli.Tests; + +public class GeneratorTests +{ + [Fact] + public void Generate_Default_CreatesValidUlid() + { + var ulid = Generator.Generate(); + Assert.NotEqual(default, ulid); + Assert.Equal(26, ulid.ToString().Length); + } + + [Fact] + public void Generate_WithTimestamp_CreatesUlidWithSpecifiedTime() + { + const long expectedMs = 1790683200000; // 2026-09-29T12:00:00Z + var ulid = Generator.Generate(timestamp: expectedMs, randomBytes: new byte[10]); + + Assert.Equal(expectedMs, ulid.Time.ToUnixTimeMilliseconds()); + } + + [Fact] + public void Generate_WithMaximumTimestamp_CreatesUlid() + { + var ulid = Generator.Generate(timestamp: InputParser.MaxTimestampMs, randomBytes: new byte[10]); + var result = Inspector.Inspect(ulid); + + Assert.StartsWith("FFFFFFFFFFFF", Convert.ToHexString(ulid.ToByteArray())); + Assert.Equal(InputParser.MaxTimestampMs, result.Timestamp); + Assert.Equal("N/A", result.TimeIso); + } + + [Theory] + [InlineData(-1)] + [InlineData(281474976710656)] + public void Generate_WithOutOfRangeTimestamp_ThrowsArgumentOutOfRangeException(long timestamp) + { + Assert.Throws(() => Generator.Generate(timestamp)); + } + + [Fact] + public void Generate_WithRandomBytes_CreatesUlidWithExactRandomBytes() + { + byte[] randomBytes = [0x5D, 0x67, 0x62, 0x47, 0x7A, 0xCB, 0xF9, 0x13, 0x05, 0x87]; + var ulid = Generator.Generate(randomBytes: randomBytes); + + Assert.True(randomBytes.AsSpan().SequenceEqual(ulid.Random)); + } + + [Fact] + public void Generate_WithDateTimeOffset_CreatesUlidWithSpecifiedTime() + { + var ulid = Generator.Generate(1790683200000, new byte[10]); + + Assert.Equal(1790683200000, ulid.Time.ToUnixTimeMilliseconds()); + } + + [Fact] + public void Generate_WithInvalidRandomBytesLength_ThrowsArgumentException() + { + byte[] invalidLength = [0x01, 0x02, 0x03]; + Assert.Throws(() => Generator.Generate(randomBytes: invalidLength)); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/InputParserTests.cs b/src/Cli.Tests/InputParserTests.cs new file mode 100644 index 0000000..9863e7f --- /dev/null +++ b/src/Cli.Tests/InputParserTests.cs @@ -0,0 +1,98 @@ +namespace ByteAether.Ulid.Cli.Tests; + +public class InputParserTests +{ + [Theory] + [InlineData("1790683200000", 1790683200000)] + [InlineData("2026-09-29T12:00:00Z", 1790683200000)] + [InlineData("2026-09-29T15:00:00+03:00", 1790683200000)] + public void TryParseTime_TimestampInputs_ReturnsExpectedMs(string input, long expectedMs) + { + var success = InputParser.TryParseTime(input, out var actualMs, out var error); + + Assert.True(success); + Assert.Null(error); + Assert.Equal(expectedMs, actualMs); + } + + [Theory] + [InlineData("definitely not a date")] + public void TryParseTime_InvalidTimestamp_ReturnsCombinedError(string input) + { + var success = InputParser.TryParseTime(input, out _, out var error); + + Assert.False(success); + Assert.Contains("Invalid timestamp/time bytes", error); + } + + [Theory] + [InlineData("-1", "out of range")] + [InlineData("281474976710656", "out of range")] + [InlineData("1969-12-31T23:59:59Z", "out of the valid 48-bit ULID range")] + public void TryParseTime_OutOfRangeTimestamp_PreservesRangeError(string input, string expectedError) + { + var success = InputParser.TryParseTime(input, out _, out var error); + + Assert.False(success); + Assert.Contains(expectedError, error); + } + + [Theory] + [InlineData("01A0ED093A00")] + [InlineData("0x01A0ED093A00")] + [InlineData("01-A0-ED-09-3A-00")] + [InlineData("01 A0 ED 09 3A 00")] + [InlineData("01M3PGJEG0")] // Crockford 10 chars + public void TryParseTimeBytes_ValidInputs_Returns6Bytes(string input) + { + byte[] expected = [0x01, 0xA0, 0xED, 0x09, 0x3A, 0x00]; + + var success = InputParser.TryParseTimeBytes(input, out var timeBytes, out var error); + + Assert.True(success); + Assert.Null(error); + Assert.NotNull(timeBytes); + Assert.Equal(expected, timeBytes); + } + + [Theory] + [InlineData("5D6762477ACBF9130587")] + [InlineData("0x5D6762477ACBF9130587")] + [InlineData("5D-67-62-47-7A-CB-F9-13-05-87")] + [InlineData("BNKP4HVTSFWH61C7")] // Crockford 16 chars + public void TryParseRandomBytes_ValidInputs_Returns10Bytes(string input) + { + byte[] expected = [0x5D, 0x67, 0x62, 0x47, 0x7A, 0xCB, 0xF9, 0x13, 0x05, 0x87]; + + var success = InputParser.TryParseRandomBytes(input, out var randomBytes, out var error); + + Assert.True(success); + Assert.Null(error); + Assert.NotNull(randomBytes); + Assert.Equal(expected, randomBytes); + } + + [Theory] + [InlineData("AAAAAAAA")] // Too short (4 bytes) + [InlineData("AAAAAAAAAAAAAA")] // Too long (7 bytes) + [InlineData("INVALID_HEX")] + public void TryParseTimeBytes_InvalidInputs_ReturnsFalse(string input) + { + var success = InputParser.TryParseTimeBytes(input, out _, out var error); + + Assert.False(success); + Assert.NotNull(error); + } + + [Theory] + [InlineData("5D6762477A")] // Too short (5 bytes) + [InlineData("5D6762477ACBF913058700")] // Too long (11 bytes) + [InlineData("INVALID_RANDOM")] + public void TryParseRandomBytes_InvalidInputs_ReturnsFalse(string input) + { + var success = InputParser.TryParseRandomBytes(input, out _, out var error); + + Assert.False(success); + Assert.NotNull(error); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/InspectorTests.cs b/src/Cli.Tests/InspectorTests.cs new file mode 100644 index 0000000..8f6eade --- /dev/null +++ b/src/Cli.Tests/InspectorTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; + +namespace ByteAether.Ulid.Cli.Tests; + +public class InspectorTests +{ + [Fact] + public void Inspect_CanonicalUlid_ExtractsAllComponentsCorrectly() + { + const string ulidString = "01M3PGJEG0BNKP4HVTSFWH61C7"; + var success = Inspector.TryInspect(ulidString, out var result, out _); + Assert.True(success); + + Assert.Equal(ulidString, result!.Ulid); + Assert.Equal("01A0ED093A005D6762477ACBF9130587", result.Hex); + Assert.Equal("01M3PGJEG0", result.Time); + Assert.Equal("01A0ED093A00", result.TimeHex); + Assert.Equal("BNKP4HVTSFWH61C7", result.Random); + Assert.Equal("5D6762477ACBF9130587", result.RandomHex); + Assert.Equal(1790683200000, result.Timestamp); + Assert.Equal("2026-09-29T12:00:00.000Z", result.TimeIso); + } + + [Theory] + [InlineData(253402300799999, "9999-12-31T23:59:59.999Z")] + [InlineData(253402300800000, "N/A")] + public void Inspect_TimestampBoundary_FormatsIsoTimestamp(long timestamp, string expectedIso) + { + var result = Inspector.Inspect(Ulid.New(timestamp, new byte[10])); + + Assert.Equal(expectedIso, result.TimeIso); + } + + [Fact] + public void Inspect_HexRepresentation_ExtractsAllComponentsCorrectly() + { + // 16 bytes = 01A0ED093A00 + 5D6762477ACBF9130587 = 32 hex chars + const string hex = "01A0ED093A005D6762477ACBF9130587"; + //var result = Inspector.Inspect(hex); + var success = Inspector.TryInspect(hex, out var result, out _); + Assert.True(success); + + Assert.Equal("01M3PGJEG0BNKP4HVTSFWH61C7", result!.Ulid); + Assert.Equal(hex, result.Hex); + Assert.Equal("01M3PGJEG0", result.Time); + Assert.Equal("01A0ED093A00", result.TimeHex); + Assert.Equal("BNKP4HVTSFWH61C7", result.Random); + Assert.Equal("5D6762477ACBF9130587", result.RandomHex); + } + + [Fact] + public void TryInspect_InvalidString_ReturnsFalseWithErrorMessage() + { + var success = Inspector.TryInspect("invalid_not_an_ulid", out var result, out var message); + Assert.False(success); + + Assert.Null(result); + Assert.NotNull(message); + } + + [Fact] + public void ToGreppableText_ContainsAllRequiredLabels() + { + var success = Inspector.TryInspect("01M3PGJEG0BNKP4HVTSFWH61C7", out var result, out _); + Assert.True(success); + + var text = result!.ToGreppableText(); + + Assert.Contains("Ulid: 01M3PGJEG0BNKP4HVTSFWH61C7", text); + Assert.Contains("Hex: 01A0ED093A005D6762477ACBF9130587", text); + Assert.Contains("Time (ISO 8601): 2026-09-29T12:00:00.000Z", text); + Assert.Contains("Timestamp (Unix ms): 1790683200000", text); + Assert.Contains("Time: 01M3PGJEG0", text); + Assert.Contains("Time (Hex): 01A0ED093A00", text); + Assert.Contains("Random: BNKP4HVTSFWH61C7", text); + Assert.Contains("Random (Hex): 5D6762477ACBF9130587", text); + } + + [Fact] + public void ToJson_ProducesValidDeserializableJson() + { + var success = Inspector.TryInspect("01M3PGJEG0BNKP4HVTSFWH61C7", out var result, out _); + Assert.True(success); + + var json = JsonSerializer.Serialize(result); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("01M3PGJEG0BNKP4HVTSFWH61C7", root.GetProperty("ulid").GetString()); + Assert.Equal("01A0ED093A005D6762477ACBF9130587", root.GetProperty("hex").GetString()); + Assert.Equal("01M3PGJEG0", root.GetProperty("time").GetString()); + Assert.Equal("01A0ED093A00", root.GetProperty("timeHex").GetString()); + Assert.Equal(1790683200000, root.GetProperty("timestamp").GetInt64()); + Assert.Equal("2026-09-29T12:00:00.000Z", root.GetProperty("timeIso").GetString()); + Assert.Equal("BNKP4HVTSFWH61C7", root.GetProperty("random").GetString()); + Assert.Equal("5D6762477ACBF9130587", root.GetProperty("randomHex").GetString()); + } + + [Theory] + [InlineData("time", "01M3PGJEG0")] + [InlineData("timeHex", "01A0ED093A00")] + [InlineData("random", "BNKP4HVTSFWH61C7")] + [InlineData("randomHex", "5D6762477ACBF9130587")] + [InlineData("timestamp", "1790683200000")] + [InlineData("timeIso", "2026-09-29T12:00:00.000Z")] + [InlineData("hex", "01A0ED093A005D6762477ACBF9130587")] + [InlineData("ulid", "01M3PGJEG0BNKP4HVTSFWH61C7")] + public void TryGetPart_ReturnsExpectedValues(string partName, string expectedValue) + { + var success = Inspector.TryInspect("01M3PGJEG0BNKP4HVTSFWH61C7", out var result, out _); + Assert.True(success); + + var found = result!.TryGetPart(partName, out var value); + + Assert.True(found); + Assert.Equal(expectedValue, value); + } + + [Fact] + public void TryGetPart_InvalidPartName_ReturnsFalse() + { + var success = Inspector.TryInspect("01M3PGJEG0BNKP4HVTSFWH61C7", out var result, out _); + Assert.True(success); + + var found = result!.TryGetPart("nonexistent_property", out var value); + + Assert.False(found); + Assert.Null(value); + } +} \ No newline at end of file diff --git a/src/Cli/Cli.Impl.csproj b/src/Cli/Cli.Impl.csproj new file mode 100644 index 0000000..c0a84e1 --- /dev/null +++ b/src/Cli/Cli.Impl.csproj @@ -0,0 +1,43 @@ + + + + net6.0 + Latest + Exe + true + ulid + Major + true + + true + true + ByteAether.Ulid.Cli - Command Line Interface and .NET Tool for ULID + Official CLI companion tool and library for ByteAether.Ulid. Provides generation and inspection of Universally Unique Lexicographically Sortable Identifiers (ULIDs) with support for custom timestamps, time bytes, and random bytes, formatted for terminal and script interop. + + ByteAether.Ulid.Cli + $(PackageVersion) + logo_ulid_tool.png + ulid;cli;tool;dotnet-tool;generator;inspector;crockford-base32;guid;unique-id;csharp + PACKAGE.md + + ByteAether.Ulid.Cli + ByteAether.Ulid.Cli + + + + + + + + + + + + + + True + \ + + + + diff --git a/src/Cli/Cli.cs b/src/Cli/Cli.cs new file mode 100644 index 0000000..f3c65a3 --- /dev/null +++ b/src/Cli/Cli.cs @@ -0,0 +1,239 @@ +using System.Text.Json; +using CommandLine; + +namespace ByteAether.Ulid.Cli; + +/// +/// Main entry point for the ULID CLI companion tool. +/// +public static class Cli +{ + private static readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = true + }; + + /// + /// Runs the CLI tool with the specified command-line arguments and standard I/O streams. + /// + /// The command-line arguments. + /// The output text writer (defaults to ). + /// The error text writer (defaults to ). + /// The input text reader (defaults to ). + /// Exit code: 0 on success, non-zero on failure. + public static int Run(string[] args, TextWriter? stdout = null, TextWriter? stderr = null, TextReader? stdin = null) + { + stdout ??= Console.Out; + stderr ??= Console.Error; + stdin ??= Console.In; + + using var parser = new Parser(settings => + { + settings.CaseInsensitiveEnumValues = true; + settings.HelpWriter = stdout; + settings.AutoHelp = true; + settings.AutoVersion = true; + }); + + var parserResult = parser.ParseArguments(args); + + return parserResult.MapResult( + options => Execute(options, stdout, stderr, stdin), + errors => + { + var errorList = errors.ToList(); + return errorList.Any(e => e is HelpRequestedError or VersionRequestedError) + ? 0 + : 1; + } + ); + } + + private static int Execute(GenerateOptions options, TextWriter stdout, TextWriter stderr, TextReader stdin) + { + if (!TryNormalizeFormat(options.Format, out var format)) + { + stderr.WriteLine($"Error: Unknown format '{options.Format}'. Available formats: crockford32 (crockford, base32, canonical), hex, guid."); + return 1; + } + + using var ulids = GetUlidsToInspect(options, stdin).GetEnumerator(); + return ulids.MoveNext() + ? ExecuteInspect(ulids, options, stdout, stderr) + : ExecuteGenerate(options, format, stdout, stderr); + } + + private static IEnumerable GetUlidsToInspect(GenerateOptions options, TextReader stdin) + { + foreach (var ulid in options.Ulids) + { + if (!string.IsNullOrWhiteSpace(ulid)) + { + yield return ulid.Trim(); + } + } + + if (!HasPipedInput(stdin)) + { + yield break; + } + + while (stdin.ReadLine() is { } line) + { + if (!string.IsNullOrWhiteSpace(line)) + { + yield return line.Trim(); + } + } + } + + private static bool HasPipedInput(TextReader stdin) + { + if (!ReferenceEquals(stdin, Console.In)) + { + return stdin.Peek() >= 0; + } + + return Console.IsInputRedirected; + } + + private static int ExecuteGenerate(GenerateOptions options, string format, TextWriter stdout, TextWriter stderr) + { + if (options.Count < 1) + { + stderr.WriteLine("Error: Count must be at least 1."); + return 1; + } + + long? timestampMs = null; + + if (options.Timestamp != null) + { + if (!InputParser.TryParseTime(options.Timestamp, out timestampMs, out var timeError)) + { + stderr.WriteLine($"Error: {timeError}"); + return 1; + } + } + + byte[]? randomBytes = null; + if (options.Random != null) + { + if (!InputParser.TryParseRandomBytes(options.Random, out var parsedRb, out var rbError)) + { + stderr.WriteLine($"Error: {rbError}"); + return 1; + } + + randomBytes = parsedRb; + } + + try + { + var values = Enumerable.Range(0, options.Count) + .Select(_ => FormatUlid(Generator.Generate(timestampMs, randomBytes), format)); + if (options.Json) + { + stdout.WriteLine(options.Count == 1 + ? JsonSerializer.Serialize(values.Single(), _serializerOptions) + : JsonSerializer.Serialize(values, _serializerOptions)); + } + else + { + foreach (var value in values) + { + stdout.WriteLine(value); + } + } + } + catch (Exception ex) + { + stderr.WriteLine($"Error generating ULID: {ex.Message}"); + return 1; + } + + return 0; + } + + private static int ExecuteInspect(IEnumerator ulids, GenerateOptions options, TextWriter stdout, TextWriter stderr) + { + string? errorMessage = null; + + IEnumerable GetResults() + { + do + { + if (!Inspector.TryInspect(ulids.Current, out var result, out errorMessage)) + { + yield break; + } + + yield return result; + } + while (ulids.MoveNext()); + } + var results = GetResults(); + + IEnumerable? parts = null; + if (!string.IsNullOrWhiteSpace(options.Part)) + { + parts = GetParts(); + + IEnumerable GetParts() + { + foreach (var result in results) + { + if (!result.TryGetPart(options.Part, out var partValue)) + { + errorMessage = $"Unknown part '{options.Part}'. Available parts: ulid, hex, time, timeHex, timeIso, timestamp, random, randomHex."; + yield break; + } + + yield return partValue; + } + } + } + + var output = options.Json + ? parts is null + ? SerializeSingleOrArray(results) + : SerializeSingleOrArray(parts) + : string.Join(Environment.NewLine, parts ?? results.Select(r => r.ToGreppableText())); + + if (errorMessage is not null) + { + stderr.WriteLine($"Error: {errorMessage}"); + return 1; + } + + stdout.WriteLine(output); + return 0; + } + + private static string SerializeSingleOrArray(IEnumerable values) + { + var list = values as IReadOnlyList ?? values.ToList(); + + return list.Count switch + { + 0 => JsonSerializer.Serialize(Array.Empty(), _serializerOptions), + 1 => JsonSerializer.Serialize(list[0], _serializerOptions), + _ => JsonSerializer.Serialize(list, _serializerOptions) + }; + } + + private static bool TryNormalizeFormat(string? format, out string normalized) + { + normalized = string.IsNullOrWhiteSpace(format) ? "base32" : format.Trim().ToLowerInvariant(); + return normalized is "hex" or "guid" or "base32"; + } + + private static string FormatUlid(Ulid ulid, string format) + => format switch + { + "hex" => Convert.ToHexString(ulid.ToByteArray()), + "guid" => ulid.ToGuid().ToString(), + "base32" => ulid.ToString(), + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported ULID output format.") + }; +} \ No newline at end of file diff --git a/src/Cli/GenerateOptions.cs b/src/Cli/GenerateOptions.cs new file mode 100644 index 0000000..69500e6 --- /dev/null +++ b/src/Cli/GenerateOptions.cs @@ -0,0 +1,52 @@ +using CommandLine; + +namespace ByteAether.Ulid.Cli; + +/// +/// Command-line options for generating or inspecting ULIDs. +/// +[Verb("generate", isDefault: true, HelpText = "Generate one or more Universally Unique Lexicographically Sortable Identifiers (ULIDs).")] +public sealed class GenerateOptions +{ + /// + /// Gets or sets ULIDs to inspect. If specified, inspection is assumed. + /// + [Value(0, MetaName = "ulid", Required = false, HelpText = "ULID(s) to inspect. Accepts Crockford Base32, hex, or GUID format. Multiple values may also be piped via stdin, one per line.")] + public IEnumerable Ulids { get; set; } = []; + + /// + /// Gets or sets the timestamp or custom 6 time bytes for the ULID. + /// + [Option('t', "time", Required = false, HelpText = "Timestamp/time bytes for generated ULIDs. Accepts an ISO 8601 date string, Unix epoch milliseconds, 12-character hex time bytes, 10-character Crockford Base32 time bytes, or separated byte values.")] + public string? Timestamp { get; set; } + + /// + /// Gets or sets the custom 10 random bytes. + /// + [Option('r', "random", Required = false, HelpText = "Custom 10 random bytes as a 20-character hex string, 16-character Crockford Base32 string, or separated byte values.")] + public string? Random { get; set; } + + /// + /// Gets or sets the number of ULIDs to generate. + /// + [Option('c', "count", Default = 1, Required = false, HelpText = "Number of ULIDs to generate when no ULID input is provided (default: 1).")] + public int Count { get; set; } = 1; + + /// + /// Gets or sets a value indicating whether to output in JSON format. + /// + [Option('j', "json", Default = false, Required = false, HelpText = "Output result in JSON format.")] + public bool Json { get; set; } + + /// + /// Gets or sets the output format for generated ULIDs. + /// + [Option('f', "format", Required = false, HelpText = "Output format for generated ULIDs: 'base32' (default), 'hex', or 'guid'.")] + public string? Format { get; set; } + + /// + /// Gets or sets the part of inspected ULIDs to output. + /// + [Option('p', "part", Required = false, HelpText = "When inspecting, output only one component: 'ulid', 'hex', 'time', 'timeHex', 'timeIso', 'timestamp', 'random', or 'randomHex'.")] + public string? Part { get; set; } +} \ No newline at end of file diff --git a/src/Cli/Generator.cs b/src/Cli/Generator.cs new file mode 100644 index 0000000..b58fd83 --- /dev/null +++ b/src/Cli/Generator.cs @@ -0,0 +1,53 @@ +namespace ByteAether.Ulid.Cli; + +/// +/// Provides methods for generating ULIDs based on optional timestamp, time bytes, and random bytes. +/// +public static class Generator +{ + /// + /// Generates a new using the provided optional components and default generation options. + /// + /// Optional timestamp in milliseconds since the Unix epoch. + /// Optional 10-byte span representing the random component. + /// A new instance. + /// Thrown when invalid parameters or conflicting options are specified. + /// Thrown when the timestamp is outside the 48-bit ULID range. + public static Ulid Generate( + long? timestamp = null, + ReadOnlySpan randomBytes = default) + { + if (randomBytes.Length > 0 && randomBytes.Length != 10) + { + throw new ArgumentException("Random bytes must be exactly 10 bytes in length.", nameof(randomBytes)); + } + + long effectiveTimestamp; + if (timestamp.HasValue) + { + effectiveTimestamp = timestamp.Value; + } + else + { + effectiveTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + if (effectiveTimestamp < 0 || effectiveTimestamp > InputParser.MaxTimestampMs) + { + throw new ArgumentOutOfRangeException( + nameof(timestamp), + effectiveTimestamp, + $"Timestamp must be between 0 and {InputParser.MaxTimestampMs} milliseconds." + ); + } + + if (randomBytes.Length != 10) + { + return Ulid.New(effectiveTimestamp, Ulid.DefaultGenerationOptions); + } + + Span randomSpan = stackalloc byte[10]; + randomBytes.CopyTo(randomSpan); + return Ulid.New(effectiveTimestamp, randomSpan); + } +} \ No newline at end of file diff --git a/src/Cli/Hex.cs b/src/Cli/Hex.cs new file mode 100644 index 0000000..f799e8d --- /dev/null +++ b/src/Cli/Hex.cs @@ -0,0 +1,40 @@ +namespace ByteAether.Ulid.Cli; + +internal static class Hex +{ + public static bool TryParseBytes(string input, int expectedLength, out byte[] bytes) + { + var cleaned = Clean(input); + if (cleaned.Length != expectedLength * 2) + { + bytes = []; + return false; + } + + try + { + bytes = Convert.FromHexString(cleaned); + return true; + } + catch (FormatException) + { + bytes = []; + return false; + } + } + + public static string Clean(string input) + { + var text = input.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + text = text[2..]; + } + + return text + .Replace(" ", "") + .Replace("-", "") + .Replace(":", "") + .Replace(",", ""); + } +} diff --git a/src/Cli/InputParser.cs b/src/Cli/InputParser.cs new file mode 100644 index 0000000..5360111 --- /dev/null +++ b/src/Cli/InputParser.cs @@ -0,0 +1,199 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace ByteAether.Ulid.Cli; + +/// +/// Helper methods for parsing command-line inputs into ULID components. +/// +public static class InputParser +{ + /// + /// Maximum allowable 48-bit timestamp value in milliseconds (year 10889). + /// + public const long MaxTimestampMs = 0x0000_FFFF_FFFF_FFFFL; // 281474976710655 + + /// + /// Parses a time input which may be an ISO 8601 date string, Unix epoch milliseconds, or custom 6 time bytes. + /// + public static bool TryParseTime( + string input, + [NotNullWhen(true)] out long? timestampMs, + [NotNullWhen(false)] out string? errorMessage) + { + if (TryParseTimeBytes(input, out var timeBytes, out _)) + { + if (timeBytes.Length != 6) + { + timestampMs = null; + errorMessage = "Time bytes must be exactly 6 bytes in length."; + return false; + } + + timestampMs = + ((long)timeBytes[0] << 40) | + ((long)timeBytes[1] << 32) | + ((long)timeBytes[2] << 24) | + ((long)timeBytes[3] << 16) | + ((long)timeBytes[4] << 8) | + timeBytes[5]; + + errorMessage = null; + return true; + } + + timestampMs = ParseTimestamp(input, out errorMessage); + if (timestampMs.HasValue) + { + return true; + } + + errorMessage ??= $"Invalid timestamp/time bytes '{input.Trim()}'. Expected an ISO 8601 date string, Unix epoch milliseconds, 12 hex time bytes, 10 Crockford Base32 time bytes, or separated byte values."; + return false; + } + + private static long? ParseTimestamp( + string input, + out string? rangeError) + { + var trimmed = input.Trim(); + + // Try numeric Unix epoch milliseconds + if (long.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLong)) + { + if (parsedLong < 0 || parsedLong > MaxTimestampMs) + { + rangeError = $"Timestamp '{trimmed}' is out of range. It must be between 0 and {MaxTimestampMs} milliseconds."; + return null; + } + + rangeError = null; + return parsedLong; + } + + // Try ISO 8601 / DateTimeOffset + if (DateTimeOffset.TryParse( + trimmed, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsedDto + )) + { + var ms = parsedDto.ToUnixTimeMilliseconds(); + if (ms < 0 || ms > MaxTimestampMs) + { + rangeError = $"Date '{trimmed}' evaluates to {ms} ms, which is out of the valid 48-bit ULID range (0 to {MaxTimestampMs})."; + return null; + } + + rangeError = null; + return ms; + } + + rangeError = null; + return null; + } + + /// + /// Parses custom 6 time bytes from hex or 10-character Crockford Base32. + /// + public static bool TryParseTimeBytes( + string input, + [NotNullWhen(true)] out byte[]? timeBytes, + [NotNullWhen(false)] out string? errorMessage) + { + var trimmed = input.Trim(); + + if (Hex.TryParseBytes(trimmed, 6, out var hexBytes)) + { + timeBytes = hexBytes; + errorMessage = null; + return true; + } + + // Check if provided as 10-char Crockford Base32 + if (trimmed.Length == 10 && Ulid.TryParse(trimmed + "0000000000000000", null, out var dummyUlid)) + { + timeBytes = dummyUlid.TimeBytes.ToArray(); + errorMessage = null; + return true; + } + + // Check if provided as comma/space/dash-separated byte values + if (TryParseSeparatedBytes(trimmed, 6, out timeBytes)) + { + errorMessage = null; + return true; + } + + timeBytes = null; + errorMessage = $"Invalid time bytes '{trimmed}'. Expected 12 hex characters (e.g. '018D3A5F89B2') or 10 Crockford Base32 characters (e.g. '01AN4Z07BY')."; + return false; + } + + /// + /// Parses custom 10 random bytes from hex or 16-character Crockford Base32. + /// + public static bool TryParseRandomBytes( + string input, + [NotNullWhen(true)] out byte[]? randomBytes, + [NotNullWhen(false)] out string? errorMessage) + { + var trimmed = input.Trim(); + + if (Hex.TryParseBytes(trimmed, 10, out var hexBytes)) + { + randomBytes = hexBytes; + errorMessage = null; + return true; + } + + // Check if provided as 16-char Crockford Base32 + if (trimmed.Length == 16 && Ulid.TryParse("0000000000" + trimmed, null, out var dummyUlid)) + { + randomBytes = dummyUlid.Random.ToArray(); + errorMessage = null; + return true; + } + + // Check if provided as comma/space/dash-separated byte values + if (TryParseSeparatedBytes(trimmed, 10, out randomBytes)) + { + errorMessage = null; + return true; + } + + randomBytes = null; + errorMessage = $"Invalid random bytes '{trimmed}'. Expected 20 hex characters or 16 Crockford Base32 characters."; + return false; + } + + private static bool TryParseSeparatedBytes(string input, int expectedCount, [NotNullWhen(true)] out byte[]? bytes) + { + var parts = input.Split([',', ' ', '-'], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != expectedCount) + { + bytes = null; + return false; + } + + var result = new byte[expectedCount]; + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i].Trim(); + var isHex = part.StartsWith("0x", StringComparison.OrdinalIgnoreCase); + var value = isHex ? part[2..] : part; + var style = isHex ? NumberStyles.HexNumber : NumberStyles.Integer; + if (byte.TryParse(value, style, CultureInfo.InvariantCulture, out result[i])) + { + continue; + } + + bytes = null; + return false; + } + + bytes = result; + return true; + } +} \ No newline at end of file diff --git a/src/Cli/InspectionResult.cs b/src/Cli/InspectionResult.cs new file mode 100644 index 0000000..3508a1d --- /dev/null +++ b/src/Cli/InspectionResult.cs @@ -0,0 +1,68 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json.Serialization; + +namespace ByteAether.Ulid.Cli; + +/// +/// Represents the inspected breakdown of a ULID. +/// +public sealed record InspectionResult( + [property: JsonPropertyName("ulid")] + string Ulid, + [property: JsonPropertyName("hex")] + string Hex, + [property: JsonPropertyName("time")] + string Time, + [property: JsonPropertyName("timeHex")] + string TimeHex, + [property: JsonPropertyName("timeIso")] + string TimeIso, + [property: JsonPropertyName("timestamp")] + long Timestamp, + [property: JsonPropertyName("random")] + string Random, + [property: JsonPropertyName("randomHex")] + string RandomHex +) +{ + /// + /// Formats the result as structured, easily greppable key-value lines. + /// + public string ToGreppableText() + { + return + $"Ulid: {Ulid}\n" + + $"Hex: {Hex}\n" + + $"Time: {Time}\n" + + $"Time (Hex): {TimeHex}\n" + + $"Time (ISO 8601): {TimeIso}\n" + + $"Timestamp (Unix ms): {Timestamp.ToString(CultureInfo.InvariantCulture)}\n" + + $"Random: {Random}\n" + + $"Random (Hex): {RandomHex}\n"; + } + + /// + /// Attempts to extract a single named component value. + /// + /// The component name to extract. + /// When this method returns, contains the component value if found; otherwise, null. + /// True if the component was recognized; otherwise, false. + public bool TryGetPart(string partName, [NotNullWhen(true)] out string? value) + { + var normalized = partName.Trim().ToLowerInvariant().Replace(" ", ""); + value = normalized switch + { + "ulid" => Ulid, + "hex" => Hex, + "time" => Time, + "timehex" => TimeHex, + "timeiso" => TimeIso, + "timestamp" => Timestamp.ToString(CultureInfo.InvariantCulture), + "random" => Random, + "randomhex" => RandomHex, + _ => null + }; + return value is not null; + } +} \ No newline at end of file diff --git a/src/Cli/Inspector.cs b/src/Cli/Inspector.cs new file mode 100644 index 0000000..cd588f1 --- /dev/null +++ b/src/Cli/Inspector.cs @@ -0,0 +1,88 @@ +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace ByteAether.Ulid.Cli; + +/// +/// Provides methods for inspecting existing ULIDs and analyzing their time and randomness components. +/// +public static class Inspector +{ + /// + /// Inspects a given instance. + /// + /// The ULID to inspect. + /// An detailing the ULID components. + public static InspectionResult Inspect(Ulid ulid) + { + var ulidString = ulid.ToString(); + var bytes = ulid.ToByteArray(); + var ulidHex = Convert.ToHexString(bytes); + var timestamp = (long)(BinaryPrimitives.ReadUInt64BigEndian(bytes) >> 16); + var timeIso = timestamp <= DateTimeOffset.MaxValue.ToUnixTimeMilliseconds() + ? DateTimeOffset.FromUnixTimeMilliseconds(timestamp).ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture) + : "N/A"; + + return new( + Ulid: ulidString, + Hex: ulidHex, + Time: ulidString[..10], + TimeHex: ulidHex[..12], + TimeIso: timeIso, + Timestamp: timestamp, + Random: ulidString[10..], + RandomHex: ulidHex[12..] + ); + } + + /// + /// Attempts to inspect a string representing a ULID in canonical Crockford Base32, hex, or GUID format. + /// + /// The string to inspect. + /// When this method returns, contains the inspection result if successful; otherwise, null. + /// When this method returns, contains an error message if parsing failed; otherwise, null. + /// True if inspection succeeded; otherwise, false. + public static bool TryInspect( + string input, + [NotNullWhen(true)] out InspectionResult? result, + [NotNullWhen(false)] out string? errorMessage) + { + if (string.IsNullOrWhiteSpace(input)) + { + result = null; + errorMessage = "ULID input cannot be empty."; + return false; + } + + var trimmed = input.Trim(); + + // 1. Try canonical Crockford Base32 representation (26 chars) + if (Ulid.TryParse(trimmed, null, out var ulid)) + { + result = Inspect(ulid); + errorMessage = null; + return true; + } + + // 2. Try raw 32-character Hex representation (16 bytes) + if (Hex.TryParseBytes(trimmed, 16, out var bytes)) + { + result = Inspect(Ulid.New(bytes)); + errorMessage = null; + return true; + } + + // 3. Try standard GUID representation + if (Guid.TryParse(trimmed, out var guid)) + { + result = Inspect(Ulid.New(guid)); + errorMessage = null; + return true; + } + + result = null; + errorMessage = $"The input '{trimmed}' is not a valid ULID. Expected 26 Crockford Base32 characters, 32 hex characters, or a GUID format."; + return false; + } +} \ No newline at end of file diff --git a/src/Cli/PACKAGE.md b/src/Cli/PACKAGE.md new file mode 100644 index 0000000..f07d42b --- /dev/null +++ b/src/Cli/PACKAGE.md @@ -0,0 +1,251 @@ +# ULID .NET Tool +*from ByteAether* + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.Cli?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.Cli/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.Cli?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.Cli/) +![.NET 6.0+](https://img.shields.io/badge/.NET-6.0+-brightgreen) + +`ByteAether.Ulid.Cli` is a .NET command-line tool for generating and inspecting ULIDs. It supports canonical Crockford's Base32, hexadecimal, and GUID representations, custom time and randomness components, batch operations, JSON output, and pipelines. + +## ✨ Features + +- **Flexible Installation**: Install globally or locally as the `ulid` command. +- **ULID Generation**: + - Generate canonical [Crockford's Base32](https://www.crockford.com/base32.html), Hex, or GUID representations. + - Customize time components using ISO 8601 strings, Unix epoch milliseconds, Hex, or Crockford's Base32. + - Customize randomness components using Hex or Crockford's Base32. + - Batch generation via `--count` / `-c`. +- **ULID Inspection**: + - Decode canonical strings, Hex, or GUIDs provided as arguments or piped via `stdin`. + - Extract specific components using `--part` for shell scripting (`grep`, `awk`, `cut`). + - Output full breakdowns as human-readable text or structured JSON (`--json`). + +## 💾 Installation + +Install globally: + +```sh +dotnet tool install -g ByteAether.Ulid.Cli +``` + +Or install locally in a repository: + +```sh +dotnet new tool-manifest # if the repository does not have a tool manifest +dotnet tool install ByteAether.Ulid.Cli +``` + +Run a global installation with `ulid` and a local installation with `dotnet ulid`. + +## 🚀 Usage + +```sh +# Generate one ULID +ulid + +# Generate five ULIDs +ulid --count 5 + +# Inspect one or more ULIDs +ulid 01AN4Z07BY79KA1307SR9X4MV3 + +# Inspect ULIDs from standard input, one per line +cat ulids.txt | ulid --json +``` + +The tool runs in **generation mode** when no positional arguments or piped standard input lines are provided; otherwise, it runs in **inspection mode**. Blank arguments and lines are ignored. Positional arguments are inspected before standard-input lines. + +> *When run interactively in a terminal without positional arguments or redirected input (`stdin`), the tool immediately enters generation mode rather than waiting for `stdin` input.* + +## ⚡ Generate ULIDs + +```sh +ulid [options] +``` + +| Option | Description | +|---------------------------|------------------------------------------------------------------------------| +| `-c`, `--count ` | Number of ULIDs to generate (default: `1`; must be at least `1`). | +| `-t`, `--time ` | Timestamp or custom 6-byte time component. Defaults to the current UTC time. | +| `-r`, `--random ` | Custom 10-byte random component. Defaults to generated randomness. | +| `-f`, `--format ` | Output format: `base32` (default), `hex`, or `guid`. | +| `-j`, `--json` | Output a JSON string for one ULID or an array of strings for multiple ULIDs. | + +### Time and random input formats + +`--time` accepts a timestamp or six time bytes: + +| Format | Example | +|---------------------------------------|-----------------------------------| +| ISO 8601 date/time | `2026-09-29T12:00:00Z` | +| Unix epoch milliseconds | `1790683200000` | +| 10 Crockford's Base32 characters | `01M3PGJEG0` | +| 12 hexadecimal characters | `01A0ED093A00` | +| Six separated decimal bytes | `"1,160,237,9,58,0"` | +| Six separated `0x`-prefixed hex bytes | `"0x01 0xA0 0xED 0x09 0x3A 0x00"` | + +Timestamps must be in the 48-bit ULID range: `0`–`281474976710655` milliseconds. Timestamps without a time-zone offset are assumed to be UTC, explicit offsets are converted to UTC. Ten-digit numeric values can be interpreted as Base32, and 12-digit numeric values as hexadecimal; use 11 digits or 13 or more digits to specify epoch milliseconds unambiguously (pad with leading zeros if needed). + +`--random` accepts a custom randomness component in one of these forms: + +| Format | Example | +|---------------------------------------|-------------------------------------------------------| +| 16 Crockford's Base32 characters | `BNKP4HVTSFWH61C7` | +| 20 hexadecimal characters | `5D6762477ACBF9130587` | +| Ten separated decimal bytes | `"93,103,98,71,122,203,249,19,5,135"` | +| Ten separated `0x`-prefixed hex bytes | `"0x5D 0x67 0x62 0x47 0x7A 0xCB 0xF9 0x13 0x05 0x87"` | + +Separated byte values can use commas, spaces, or hyphens. Decimal values must be between `0` and `255`. + +### Output formats + +| Format | Output | +|----------|------------------------------------------------| +| `base32` | 26-character canonical Crockford's Base32 ULID | +| `hex` | 32 uppercase hexadecimal characters | +| `guid` | Standard hyphenated GUID | + +Format names are case-insensitive. A custom `--random` value is reused for every ULID in a batch; if both `--time` and `--random` are fixed, each generated ULID in the batch is identical. + +### Generation examples + +Generate with explicit time and random components: + +```sh +ulid --time 01A0ED093A00 --random 5D6762477ACBF9130587 +``` + +```text +01M3PGJEG0BNKP4HVTSFWH61C7 +``` + +The same ULID in hexadecimal or GUID format: + +```sh +ulid --time 01A0ED093A00 --random 5D6762477ACBF9130587 --format hex +ulid --time 01A0ED093A00 --random 5D6762477ACBF9130587 --format guid +``` + +```text +01A0ED093A005D6762477ACBF9130587 +01a0ed09-3a00-5d67-6247-7acbf9130587 +``` + +Generate two ULIDs as JSON: + +```sh +ulid --count 2 --time 01A0ED093A00 --random 5D6762477ACBF9130587 --json +``` + +```json +[ + "01M3PGJEG0BNKP4HVTSFWH61C7", + "01M3PGJEG0BNKP4HVTSFWH61C7" +] +``` + +## 🔍 Inspect ULIDs + +```sh +ulid [options] +ulid [options] < ulids.txt +``` + +Input can be supplied as positional arguments, one ULID per standard-input line, or both. The tool accepts a 26-character Crockford's Base32 ULID, a 32-character hexadecimal ULID, or a GUID. Hex input may also include a leading `0x` and separators (spaces, hyphens, colons, or commas). + +| Option | Description | +|-----------------------|----------------------------------------------------------------------| +| `-j`, `--json` | Output one JSON object or, for multiple inputs, an array of objects. | +| `-p`, `--part ` | Output only the named component. Names are case-insensitive. | + +Without `--part`, the tool prints one breakdown per input with these fields: + +| Text field | Description | `--part` name | +|-----------------------|----------------------------------------------------|---------------| +| `Ulid` | Canonical 26-character Crockford's Base32 ULID | `ulid` | +| `Hex` | 32-character hexadecimal ULID | `hex` | +| `Time` | 10-character Crockford's Base32 time component | `time` | +| `Time (Hex)` | 12-character hexadecimal time component | `timeHex` | +| `Time (ISO 8601)` | UTC timestamp in `yyyy-MM-ddTHH:mm:ss.fffZ` format | `timeIso` | +| `Timestamp (Unix ms)` | Timestamp in milliseconds since the Unix epoch | `timestamp` | +| `Random` | 16-character Crockford's Base32 random component | `random` | +| `Random (Hex)` | 20-character hexadecimal random component | `randomHex` | + +JSON properties use the same names as `--part`. The full output for the sample ULID above is: + +```text +Ulid: 01M3PGJEG0BNKP4HVTSFWH61C7 +Hex: 01A0ED093A005D6762477ACBF9130587 +Time: 01M3PGJEG0 +Time (Hex): 01A0ED093A00 +Time (ISO 8601): 2026-09-29T12:00:00.000Z +Timestamp (Unix ms): 1790683200000 +Random: BNKP4HVTSFWH61C7 +Random (Hex): 5D6762477ACBF9130587 +``` + +For example, extract a field as plain text: + +```sh +ulid 01M3PGJEG0BNKP4HVTSFWH61C7 --part timeHex +``` + +```text +01A0ED093A00 +``` + +Request the same field as JSON: + +```sh +ulid 01M3PGJEG0BNKP4HVTSFWH61C7 --part timeHex --json +``` + +```json +"01A0ED093A00" +``` + +For multiple inputs, `--part` with `--json` returns an array: + +```sh +printf '%s\n' 01M3PGJEG0BNKP4HVTSFWH61C7 01M3PGJEG0BNKP4HVTSFWH61C7 | ulid --part timeHex --json +``` + +```json +[ + "01A0ED093A00", + "01A0ED093A00" +] +``` + +A full JSON breakdown is: + +```json +{ + "ulid": "01M3PGJEG0BNKP4HVTSFWH61C7", + "hex": "01A0ED093A005D6762477ACBF9130587", + "time": "01M3PGJEG0", + "timeHex": "01A0ED093A00", + "timeIso": "2026-09-29T12:00:00.000Z", + "timestamp": 1790683200000, + "random": "BNKP4HVTSFWH61C7", + "randomHex": "5D6762477ACBF9130587" +} +``` + +Without `--json`, `--part` outputs values one per line, making it easy to pipe into standard utilities: +```sh +cat ulids.txt | ulid --part timestamp | xargs -I {} echo "Processing timestamp: {}" +``` + +With `--part` and `--json`, one value is a JSON string and multiple values are an array of strings. Without `--json`, selected values are printed one per line. ULIDs with timestamps later than .NET's supported `DateTimeOffset` range are still inspectable, `timeIso` is `N/A` for those values. + +## ⚙️ Options, errors, and exit codes + +`--help` displays help and `--version` displays the tool version. Both return exit code `0`. Successful commands return `0`. Invalid arguments, ULID inputs, formats, parts, counts, or time/random values return `1` and report an error to standard error. + +Inspection output is written only after all inputs have been validated. Invalid input does not produce partial inspection output. The `--count`, `--time`, and `--random` options affect generation only. `--format` is validated regardless of mode but affects generated output only. + +## 📜 License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/ByteAether/Ulid/blob/main/LICENSE) file for details. diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs new file mode 100644 index 0000000..0add356 --- /dev/null +++ b/src/Cli/Program.cs @@ -0,0 +1,3 @@ +using ByteAether.Ulid.Cli; + +return Cli.Run(args); \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 2de1db2..115f37b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,6 +7,8 @@ + +