diff --git a/README.md b/README.md
index 10fc4d4..3db4c85 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,11 @@ var guid = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org
var guidv3 = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org"u8, version: 3);
```
+### ULID Compatibility
+
+Because UUIDv7 and [ULID](https://github.com/ulid/spec) share the same format (48-bit Unix timestamp,
+80 bits of random data), any v7 GUID can be converted to a ULID string by using `GuidHelpers.ToUlidString`.
+
### Experimental
The following APIs are based on the current [working draft](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis)
@@ -37,6 +42,9 @@ var guidv6 = GuidHelpers.CreateVersion6FromVersion1(GuidHelpers.DnsNamespace);
// creates a v7 GUID using the current time and random data
var guidv7 = GuidHelpers.CreateVersion7();
+// any v7 GUID can be converted to a ULID string
+var ulid = GuidHelpers.ToUlidString(guidv7);
+
// .NET 8 only: specify a TimeProvider to provide the timestamp
var guidv7WithTime = GuidHelpers.CreateVersion7(TimeProvider.System);
diff --git a/src/NGuid/GuidHelpers.cs b/src/NGuid/GuidHelpers.cs
index 877b863..6e798c0 100644
--- a/src/NGuid/GuidHelpers.cs
+++ b/src/NGuid/GuidHelpers.cs
@@ -299,6 +299,147 @@ private static Guid CreateVersion7(DateTimeOffset timestamp)
bytes[6], bytes[7], bytes[8], bytes[9]);
}
+ ///
+ /// Returns the specified UUIDv7 as a string in ULID format.
+ ///
+ /// The to format as a ULID string.
+ /// A ULID string for the specified .
+ public static string ToUlidString(Guid guid)
+ {
+#if NET6_0_OR_GREATER
+ return string.Create(26, guid, (c, g) =>
+ {
+ if (!TryFormatUlid(g, c, out _))
+ {
+ // we have provided a large-enough buffer, so the only reason for failure is an incorrect UUID version
+ throw new ArgumentException("The GUID must be a version 7 UUID.", nameof(guid));
+ }
+ });
+#else
+ var chars = new char[26];
+ if (!TryFormatUlid(guid, chars, out _))
+ {
+ // we have provided a large-enough buffer, so the only reason for failure is an incorrect UUID version
+ throw new ArgumentException("The GUID must be a version 7 UUID.", nameof(guid));
+ }
+ return new string(chars);
+#endif
+ }
+
+ ///
+ /// Tries to format the specified GUID into the provided character span as a ULID.
+ ///
+ /// The to format.
+ /// The span in which to write the GUID as a span of characters in ULID format.
+ /// There must be at least 26 characters available for the formatting operation to succeed.
+ /// When this method returns, contains the number of characters written into the span.
+ /// true if the formatting operation was successful; otherwise, false.
+ /// Only Version 7 UUID instances can be formatted as a ULID.
+ public static bool TryFormatUlid(Guid guid, Span destination, out int charsWritten)
+ {
+ if (destination.Length < 26)
+ {
+ charsWritten = 0;
+ return false;
+ }
+
+ // check that the GUID is a version 7 GUID
+#if NET6_0_OR_GREATER
+ Span guidBytes = stackalloc byte[16];
+ if (!guid.TryWriteBytes(guidBytes) || (guidBytes[7] & 0xF0) != 0x70)
+ {
+ charsWritten = 0;
+ return false;
+ }
+#else
+ var guidBytes = guid.ToByteArray();
+ if ((guidBytes[7] & 0xF0) != 0x70)
+ {
+ charsWritten = 0;
+ return false;
+ }
+#endif
+
+ FormatUlid(guidBytes, CrockfordBase32Chars, destination);
+
+ charsWritten = 26;
+ return true;
+ }
+
+ ///
+ /// Tries to format the specified GUID into the provided byte span as a ULID encoded as UTF-8.
+ ///
+ /// The to format.
+ /// The span in which to write the GUID as a span of UTF-8 bytes in ULID format.
+ /// There must be at least 26 bytes available for the formatting operation to succeed.
+ /// When this method returns, contains the number of bytes written into the span.
+ /// true if the formatting operation was successful; otherwise, false.
+ /// Only Version 7 UUID instances can be formatted as a ULID.
+ public static bool TryFormatUlid(Guid guid, Span destination, out int bytesWritten)
+ {
+ if (destination.Length < 26)
+ {
+ bytesWritten = 0;
+ return false;
+ }
+
+ // check that the GUID is a version 7 GUID
+#if NET6_0_OR_GREATER
+ Span guidBytes = stackalloc byte[16];
+ if (!guid.TryWriteBytes(guidBytes) || (guidBytes[7] & 0xF0) != 0x70)
+ {
+ bytesWritten = 0;
+ return false;
+ }
+#else
+ var guidBytes = guid.ToByteArray();
+ if ((guidBytes[7] & 0xF0) != 0x70)
+ {
+ bytesWritten = 0;
+ return false;
+ }
+#endif
+
+ FormatUlid(guidBytes, CrockfordBase32Bytes, destination);
+
+ bytesWritten = 26;
+ return true;
+ }
+
+ private static void FormatUlid(ReadOnlySpan guidBytes, ReadOnlySpan base32, Span output)
+ where T : struct
+ {
+ // encode the 48-bit timestamp to Base32, swapping the byte order to big-endian
+ output[0] = base32[guidBytes[3] >> 5];
+ output[1] = base32[guidBytes[3] & 0x1F];
+ output[2] = base32[guidBytes[2] >> 3];
+ output[3] = base32[((guidBytes[2] & 0x7) << 2) | (guidBytes[1] >> 6)];
+ output[4] = base32[(guidBytes[1] & 0x3E) >> 1];
+ output[5] = base32[((guidBytes[1] & 0x1) << 4) | (guidBytes[0] >> 4)];
+ output[6] = base32[((guidBytes[0] & 0xF) << 1) | (guidBytes[5] >> 7)];
+ output[7] = base32[(guidBytes[5] & 0x7C) >> 2];
+ output[8] = base32[((guidBytes[5] & 0x3) << 3) | (guidBytes[4] >> 5)];
+ output[9] = base32[guidBytes[4] & 0x1F];
+
+ // encode the 80-bit randomness to Base32
+ output[10] = base32[guidBytes[7] >> 3];
+ output[11] = base32[((guidBytes[7] & 0x7) << 2) | (guidBytes[6] >> 6)];
+ output[12] = base32[(guidBytes[6] & 0x3E) >> 1];
+ output[13] = base32[((guidBytes[6] & 0x1) << 4) | (guidBytes[8] >> 4)];
+ output[14] = base32[((guidBytes[8] & 0xF) << 1) | (guidBytes[9] >> 7)];
+ output[15] = base32[(guidBytes[9] & 0x7C) >> 2];
+ output[16] = base32[((guidBytes[9] & 0x3) << 3) | (guidBytes[10] >> 5)];
+ output[17] = base32[guidBytes[10] & 0x1F];
+ output[18] = base32[guidBytes[11] >> 3];
+ output[19] = base32[((guidBytes[11] & 0x7) << 2) | (guidBytes[12] >> 6)];
+ output[20] = base32[(guidBytes[12] & 0x3E) >> 1];
+ output[21] = base32[((guidBytes[12] & 0x1) << 4) | (guidBytes[13] >> 4)];
+ output[22] = base32[((guidBytes[13] & 0xF) << 1) | (guidBytes[14] >> 7)];
+ output[23] = base32[(guidBytes[14] & 0x7C) >> 2];
+ output[24] = base32[((guidBytes[14] & 0x3) << 3) | (guidBytes[15] >> 5)];
+ output[25] = base32[guidBytes[15] & 0x1F];
+ }
+
///
/// Creates a Version 8 UUID from 122 bits of the specified input. All byte values will be copied to the returned
/// except for the reserved version and variant bits, which will be set to 8
@@ -519,6 +660,13 @@ private static void SwapBytes(Span guid, int left, int right)
(Unsafe.Add(ref first, right), Unsafe.Add(ref first, left)) = (Unsafe.Add(ref first, left), Unsafe.Add(ref first, right));
}
+#if NET6_0_OR_GREATER
+ private static ReadOnlySpan CrockfordBase32Chars => "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+#else
+ private static ReadOnlySpan CrockfordBase32Chars => "0123456789ABCDEFGHJKMNPQRSTVWXYZ".AsSpan();
+#endif
+ private static ReadOnlySpan CrockfordBase32Bytes => "0123456789ABCDEFGHJKMNPQRSTVWXYZ"u8;
+
// UUID v1 and v6 uses a count of 100-nanosecond intervals since 00:00:00.00 UTC, 15 October 1582
private static readonly DateTime s_gregorianEpoch = new DateTime(1582, 10, 15, 0, 0, 0, DateTimeKind.Utc);
}
diff --git a/src/NGuid/README.md b/src/NGuid/README.md
index e3f41a3..e33694b 100644
--- a/src/NGuid/README.md
+++ b/src/NGuid/README.md
@@ -20,6 +20,11 @@ var guid = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org
var guidv3 = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org"u8, version: 3);
```
+### ULID Compatibility
+
+Because UUIDv7 and [ULID](https://github.com/ulid/spec) share the same format (48-bit Unix timestamp,
+80 bits of random data), any v7 GUID can be converted to a ULID string by using `GuidHelpers.ToUlidString`.
+
### Experimental
The following APIs are based on the current [working draft](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis)
@@ -32,6 +37,9 @@ var guidv6 = GuidHelpers.CreateVersion6FromVersion1(GuidHelpers.DnsNamespace);
// creates a v7 GUID using the current time and random data
var guidv7 = GuidHelpers.CreateVersion7();
+// any v7 GUID can be converted to a ULID string
+var ulid = GuidHelpers.ToUlidString(guidv7);
+
// .NET 8 only: specify a TimeProvider to provide the timestamp
var guidv7WithTime = GuidHelpers.CreateVersion7(TimeProvider.System);
diff --git a/tests/NGuid.Tests/GuidHelpersTests.cs b/tests/NGuid.Tests/GuidHelpersTests.cs
index cd17eac..4c35b55 100644
--- a/tests/NGuid.Tests/GuidHelpersTests.cs
+++ b/tests/NGuid.Tests/GuidHelpersTests.cs
@@ -122,6 +122,50 @@ public void ConvertV0ToV6() =>
public void ConvertV4ToV6() =>
Assert.Throws(() => GuidHelpers.CreateVersion6FromVersion1(Guid.NewGuid()));
+ [Theory]
+ [InlineData(0, false, 0)]
+ [InlineData(25, false, 0)]
+ [InlineData(26, true, 26)]
+ [InlineData(40, true, 26)]
+ public void TryFormatUlidChars(int bufferSize, bool expectedSuccess, int expectedCharsWritten)
+ {
+ var buffer = new char[bufferSize];
+ var guid = GuidHelpers.CreateVersion7();
+ var success = GuidHelpers.TryFormatUlid(guid, buffer, out var charsWritten);
+ Assert.Equal(expectedSuccess, success);
+ Assert.Equal(expectedCharsWritten, charsWritten);
+ }
+
+ [Theory]
+ [InlineData(0, false, 0)]
+ [InlineData(25, false, 0)]
+ [InlineData(26, true, 26)]
+ [InlineData(40, true, 26)]
+ public void TryFormatUlidBytes(int bufferSize, bool expectedSuccess, int expectedBytesWritten)
+ {
+ Span buffer = stackalloc byte[bufferSize];
+ var guid = GuidHelpers.CreateVersion7();
+ var success = GuidHelpers.TryFormatUlid(guid, buffer, out var bytesWritten);
+ Assert.Equal(expectedSuccess, success);
+ Assert.Equal(expectedBytesWritten, bytesWritten);
+ }
+
+#if NET8_0_OR_GREATER
+ [Theory]
+ [InlineData(0L, "0000000000")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L74
+ [InlineData(1L, "0000000001")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L79
+ [InlineData(0xFFL, "000000007Z")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L92
+ [InlineData(0x100L, "0000000080")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L93
+ [InlineData(1469918176385L, "01ARYZ6S41")] // https://github.com/ulid/javascript/blob/a5831206a11636c94d4657b9e1a1354c529ee4e9/test.js#L149-L151
+ [InlineData(253402300799999L, "76EZ91ZPZZ")] // https://github.com/RobThree/NUlid/blob/21e9dc80c9891d3f7ac957889ab19819f9180bf0/NUlid.Tests/UlidTests.cs#L187C5-L191
+ public void CreateUlidWithSpecifiedTime(long unixTimeMs, string expectedPrefix)
+ {
+ var timeProvider = new FixedTimeProvider(DateTimeOffset.FromUnixTimeMilliseconds(unixTimeMs));
+ var guid = GuidHelpers.CreateVersion7(timeProvider);
+ Assert.StartsWith(expectedPrefix, GuidHelpers.ToUlidString(guid), StringComparison.Ordinal);
+ }
+#endif
+
[Theory]
[InlineData("00112233445566778899AABBCCDDEEFF", "00112233-4455-8677-8899-aabbccddeeff")]
[InlineData("112233445566778899AABBCCDDEEFF00", "11223344-5566-8788-99aa-bbccddeeff00")]