diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml
index 7a62aaa..e3abc19 100644
--- a/.github/workflows/nuget-publish.yml
+++ b/.github/workflows/nuget-publish.yml
@@ -14,7 +14,7 @@ jobs:
id-token: write # enable GitHub OIDC token issuance for this job
strategy:
matrix:
- dotnet-version: [8.x, 9.x] # test all supported versions
+ dotnet-version: [8.x, 9.x, 10.x] # test all supported versions
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -34,18 +34,18 @@ jobs:
run: dotnet test --configuration Release --no-build --verbosity normal -maxcpucount:1
- name: Pack
- if: matrix.dotnet-version == '9.x'
+ if: matrix.dotnet-version == '10.x'
run: dotnet pack --configuration Release --no-build --output ./nupkg
- - name: Nuget Login (OIDC + temp API Key)
- if: matrix.dotnet-version == '9.x'
+ - name: Nuget Login (OIDC + temp API Key)
+ if: matrix.dotnet-version == '10.x'
uses: Nuget/login@v1
id: login
with:
user: ${{secrets.NUGET_USER}}
- name: Nuget push
- if: matrix.dotnet-version == '9.x'
+ if: matrix.dotnet-version == '10.x'
run: dotnet nuget push nupkg/*.nupkg --api-key ${{steps.login.outputs.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json
- name: Done
diff --git a/.gitignore b/.gitignore
index 3c4efe2..334d29f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -258,4 +258,7 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
-*.pyc
\ No newline at end of file
+*.pyc
+
+# Claude
+.claude/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..fedc238
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,3 @@
+# AGENTS.md
+
+See [CLAUDE.md](./CLAUDE.md) for all agent guidance for this repository.
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b42934b..a3ac8ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,30 @@
# Changelog
+## [4.0.0] - 2026-02-10
+
+### Added
+- **New String extensions:** `Reverse`, `ContainsIgnoreCase`, `Left`, `Right`, `WordCount`, `EnsureStartsWith`, `EnsureEndsWith`, `FromShortGuid`
+- **New DateTime extensions:** `IsWeekday`, `IsBetween`, `ToUnixTimestamp`, `StartOfMonth`, `EndOfMonth`, `StartOfWeek`, `DaysInMonth`
+- **New Integer extensions:** `IsBetween`, `IsPositive`, `IsNegative`
+- **New Decimal extensions:** `IsPositive`, `IsNegative`
+- .NET 10 support — the project now targets .NET 8, .NET 9, and .NET 10
+
+### Changed
+- Updated CI/CD pipeline to build, test, and publish from .NET 10
+
+### Fixed
+- `IsEmail` and `IsJson` parameter type changed from `string` to `string?` for consistency
+- `IsInteger` now trims input before parsing, consistent with `ParseToInt`
+- `ToKebabCase` now uses pre-compiled/source-generated regex instead of inline `Regex.Replace`
+- `FromBase64` now catches `FormatException` specifically instead of bare `catch`
+- `ToSHA256Hash` null check changed from `IsNullOrWhiteSpace` to `IsNullOrEmpty` for consistency with `ToMD5Hash`
+
+### Breaking Changes
+- **`ToSHA256Hash`** now returns lowercase hexadecimal (matching `ToMD5Hash`) instead of Base64
+- **`ToCurrency`** default culture changed from `CurrentCulture` to `InvariantCulture` for consistency with `ToMoneyFormat`
+
+---
+
## [3.0.0] - YYYY-MM-DD
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2c51406
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,57 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+OpenCode is a .NET utility library providing extension methods for common types (string, bool, int, decimal, DateTime, Guid). It is published as a NuGet package targeting .NET 8, .NET 9, and .NET 10.
+
+## Build & Test Commands
+
+```bash
+# Build
+dotnet build
+dotnet build --configuration Release
+
+# Run all tests
+dotnet test
+
+# Run tests for a specific framework
+dotnet test --framework net9.0
+
+# Run a single test class
+dotnet test --filter "FullyQualifiedName~OpenCode.Tests.StringExtension.StringExtensionCoreTests"
+
+# Run a single test method
+dotnet test --filter "FullyQualifiedName~MethodName"
+
+# Pack NuGet package
+dotnet pack --configuration Release --output ./nupkg
+```
+
+No separate lint command — code style is enforced via `.editorconfig`.
+
+## Architecture
+
+The solution has two projects:
+
+- **OpenCode/** — The library. Each type's extensions live in a subfolder (e.g., `StringExtension/`, `IntegerExtension/`). StringExtension is split across multiple files using `partial class` (e.g., `StringExtension.Core.cs`, `StringExtension.Hash.cs`, `StringExtension.Bool.cs`).
+- **OpenCode.Tests/** — xUnit tests mirroring the source folder structure 1:1.
+
+All extension classes are `public static` in the `namespace OpenCode;` (file-scoped). StringExtension classes are `partial`; other type extensions are `sealed`.
+
+## Key Conventions
+
+- **Null safety**: Extension methods handle null inputs gracefully (return defaults, empty strings, etc.) — they never throw on null. Nullable reference types are enabled.
+- **Culture**: Use `CultureInfo.InvariantCulture` for all parsing and formatting.
+- **Conditional compilation**: .NET 7+ uses source-generated regex via `[GeneratedRegex]` attributes on `private static partial Regex` methods. Older targets use `static readonly Regex` fields with `RegexOptions.Compiled`.
+- **XML docs**: All public members have ``, ``, `` documentation.
+- **Tests**: xUnit with `[Theory]`/`[InlineData]` for parameterized tests. Test class naming: `{ClassName}Tests`.
+
+## CI/CD
+
+GitHub Actions workflow (`.github/workflows/nuget-publish.yml`) triggers on push to `release/*` branches or `v*` tags. It tests on .NET 8, 9, and 10, then packs and publishes to nuget.org from the .NET 10 matrix run using OIDC authentication.
+
+## Branching
+
+Release branches follow the pattern `release/{version}` (e.g., `release/3.2`). The current main development branch is `release/3.2`.
diff --git a/OpenCode.Tests/DateTimeExtension/DateTimeExtension.UnitTest.cs b/OpenCode.Tests/DateTimeExtension/DateTimeExtension.UnitTest.cs
index aa65ec6..71dc04e 100644
--- a/OpenCode.Tests/DateTimeExtension/DateTimeExtension.UnitTest.cs
+++ b/OpenCode.Tests/DateTimeExtension/DateTimeExtension.UnitTest.cs
@@ -70,4 +70,103 @@ public void ToAge_ReturnsExpectedAge(string birthDateStr, int expectedAge)
var result = birthDate.ToAge();
Assert.Equal(expectedAge, result);
}
+
+ [Theory]
+ [InlineData(DayOfWeek.Monday, true)]
+ [InlineData(DayOfWeek.Friday, true)]
+ [InlineData(DayOfWeek.Saturday, false)]
+ [InlineData(DayOfWeek.Sunday, false)]
+ public void IsWeekday_ReturnsCorrectResult(DayOfWeek dayOfWeek, bool expected)
+ {
+ var date = new DateTime(2025, 10, 20).AddDays((int)dayOfWeek - (int)DayOfWeek.Monday);
+ Assert.Equal(expected, date.IsWeekday());
+ }
+
+ [Fact]
+ public void IsBetween_WithinRange_ReturnsTrue()
+ {
+ var date = new DateTime(2025, 6, 15);
+ var start = new DateTime(2025, 1, 1);
+ var end = new DateTime(2025, 12, 31);
+ Assert.True(date.IsBetween(start, end));
+ }
+
+ [Fact]
+ public void IsBetween_OutsideRange_ReturnsFalse()
+ {
+ var date = new DateTime(2026, 1, 1);
+ var start = new DateTime(2025, 1, 1);
+ var end = new DateTime(2025, 12, 31);
+ Assert.False(date.IsBetween(start, end));
+ }
+
+ [Fact]
+ public void IsBetween_OnBoundary_ReturnsTrue()
+ {
+ var start = new DateTime(2025, 1, 1);
+ Assert.True(start.IsBetween(start, new DateTime(2025, 12, 31)));
+ }
+
+ [Fact]
+ public void ToUnixTimestamp_ReturnsExpected()
+ {
+ var date = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var result = date.ToUnixTimestamp();
+ Assert.Equal(1735689600, result);
+ }
+
+ [Fact]
+ public void StartOfMonth_ReturnsFirstDay()
+ {
+ var date = new DateTime(2025, 10, 21, 15, 30, 45);
+ var result = date.StartOfMonth();
+ Assert.Equal(new DateTime(2025, 10, 1), result);
+ }
+
+ [Fact]
+ public void EndOfMonth_ReturnsLastDay()
+ {
+ var date = new DateTime(2025, 10, 15);
+ var result = date.EndOfMonth();
+ Assert.Equal(new DateTime(2025, 10, 31, 23, 59, 59, 999), result);
+ }
+
+ [Fact]
+ public void EndOfMonth_February_LeapYear()
+ {
+ var date = new DateTime(2024, 2, 10);
+ var result = date.EndOfMonth();
+ Assert.Equal(29, result.Day);
+ }
+
+ [Fact]
+ public void StartOfWeek_Monday_ReturnsMonday()
+ {
+ // 2025-10-23 is a Thursday
+ var date = new DateTime(2025, 10, 23);
+ var result = date.StartOfWeek(DayOfWeek.Monday);
+ Assert.Equal(new DateTime(2025, 10, 20), result);
+ Assert.Equal(DayOfWeek.Monday, result.DayOfWeek);
+ }
+
+ [Fact]
+ public void StartOfWeek_Sunday_ReturnsSunday()
+ {
+ // 2025-10-23 is a Thursday
+ var date = new DateTime(2025, 10, 23);
+ var result = date.StartOfWeek(DayOfWeek.Sunday);
+ Assert.Equal(new DateTime(2025, 10, 19), result);
+ Assert.Equal(DayOfWeek.Sunday, result.DayOfWeek);
+ }
+
+ [Theory]
+ [InlineData(2025, 2, 28)]
+ [InlineData(2024, 2, 29)]
+ [InlineData(2025, 1, 31)]
+ [InlineData(2025, 4, 30)]
+ public void DaysInMonth_ReturnsExpected(int year, int month, int expected)
+ {
+ var date = new DateTime(year, month, 1);
+ Assert.Equal(expected, date.DaysInMonth());
+ }
}
diff --git a/OpenCode.Tests/DecimalExtension/DecimalExtension.UnitTest.cs b/OpenCode.Tests/DecimalExtension/DecimalExtension.UnitTest.cs
index 127135d..15b91c9 100644
--- a/OpenCode.Tests/DecimalExtension/DecimalExtension.UnitTest.cs
+++ b/OpenCode.Tests/DecimalExtension/DecimalExtension.UnitTest.cs
@@ -55,4 +55,24 @@ public void ToCurrency_ReturnsExpectedString(decimal value, string cultureName,
var result = value.ToCurrency(culture);
Assert.Equal(expected, result);
}
+
+ [Theory]
+ [InlineData(1.5, true)]
+ [InlineData(0.001, true)]
+ [InlineData(0, false)]
+ [InlineData(-1.5, false)]
+ public void IsPositive_ReturnsExpected(decimal value, bool expected)
+ {
+ Assert.Equal(expected, value.IsPositive());
+ }
+
+ [Theory]
+ [InlineData(-1.5, true)]
+ [InlineData(-0.001, true)]
+ [InlineData(0, false)]
+ [InlineData(1.5, false)]
+ public void IsNegative_ReturnsExpected(decimal value, bool expected)
+ {
+ Assert.Equal(expected, value.IsNegative());
+ }
}
diff --git a/OpenCode.Tests/IntegerExtension/IntegerExtension.UnitTest.cs b/OpenCode.Tests/IntegerExtension/IntegerExtension.UnitTest.cs
index d06aaaa..e60b56d 100644
--- a/OpenCode.Tests/IntegerExtension/IntegerExtension.UnitTest.cs
+++ b/OpenCode.Tests/IntegerExtension/IntegerExtension.UnitTest.cs
@@ -63,4 +63,35 @@ public void Abs_ReturnsAbsoluteValue(int value, int expected)
{
Assert.Equal(expected, value.Abs());
}
+
+ [Theory]
+ [InlineData(5, 1, 10, true)]
+ [InlineData(1, 1, 10, true)]
+ [InlineData(10, 1, 10, true)]
+ [InlineData(0, 1, 10, false)]
+ [InlineData(11, 1, 10, false)]
+ public void IsBetween_ReturnsExpected(int value, int min, int max, bool expected)
+ {
+ Assert.Equal(expected, value.IsBetween(min, max));
+ }
+
+ [Theory]
+ [InlineData(1, true)]
+ [InlineData(100, true)]
+ [InlineData(0, false)]
+ [InlineData(-1, false)]
+ public void IsPositive_ReturnsExpected(int value, bool expected)
+ {
+ Assert.Equal(expected, value.IsPositive());
+ }
+
+ [Theory]
+ [InlineData(-1, true)]
+ [InlineData(-100, true)]
+ [InlineData(0, false)]
+ [InlineData(1, false)]
+ public void IsNegative_ReturnsExpected(int value, bool expected)
+ {
+ Assert.Equal(expected, value.IsNegative());
+ }
}
diff --git a/OpenCode.Tests/OpenCode.Tests.csproj b/OpenCode.Tests/OpenCode.Tests.csproj
index 79d7c4b..6a788b4 100644
--- a/OpenCode.Tests/OpenCode.Tests.csproj
+++ b/OpenCode.Tests/OpenCode.Tests.csproj
@@ -1,7 +1,7 @@
- net8.0;net9.0
+ net8.0;net9.0;net10.0
false
enable
diff --git a/OpenCode.Tests/StringExtension/StringExtension.Core.UnitTest.cs b/OpenCode.Tests/StringExtension/StringExtension.Core.UnitTest.cs
index 1a7923a..1205a7c 100644
--- a/OpenCode.Tests/StringExtension/StringExtension.Core.UnitTest.cs
+++ b/OpenCode.Tests/StringExtension/StringExtension.Core.UnitTest.cs
@@ -174,8 +174,6 @@ public void ToSlug_ReturnsExpected(string? input, string? expected)
[InlineData(null, false)]
public void IsEmail_ReturnsExpected(string? input, bool expected)
{
- if (input is null)
- input = string.Empty;
Assert.Equal(expected, input.IsEmail());
}
@@ -187,10 +185,82 @@ public void IsEmail_ReturnsExpected(string? input, bool expected)
[InlineData(null, false)]
public void IsJson_ReturnsExpected(string? input, bool expected)
{
- if (input is null)
- input = string.Empty;
Assert.Equal(expected, input.IsJson());
}
+ [Theory]
+ [InlineData("abc", "cba")]
+ [InlineData("hello", "olleh")]
+ [InlineData("a", "a")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ public void Reverse_ReturnsExpected(string? input, string expected)
+ {
+ Assert.Equal(expected, input.Reverse());
+ }
+
+ [Theory]
+ [InlineData("Hello World", "world", true)]
+ [InlineData("Hello World", "HELLO", true)]
+ [InlineData("Hello World", "xyz", false)]
+ [InlineData(null, "test", false)]
+ [InlineData("test", null, false)]
+ public void ContainsIgnoreCase_ReturnsExpected(string? input, string? value, bool expected)
+ {
+ Assert.Equal(expected, input.ContainsIgnoreCase(value));
+ }
+
+ [Theory]
+ [InlineData("abcdef", 3, "abc")]
+ [InlineData("ab", 5, "ab")]
+ [InlineData(null, 3, "")]
+ [InlineData("abc", 0, "")]
+ public void Left_ReturnsExpected(string? input, int length, string expected)
+ {
+ Assert.Equal(expected, input.Left(length));
+ }
+
+ [Theory]
+ [InlineData("abcdef", 3, "def")]
+ [InlineData("ab", 5, "ab")]
+ [InlineData(null, 3, "")]
+ [InlineData("abc", 0, "")]
+ public void Right_ReturnsExpected(string? input, int length, string expected)
+ {
+ Assert.Equal(expected, input.Right(length));
+ }
+
+ [Theory]
+ [InlineData("hello world", 2)]
+ [InlineData("one", 1)]
+ [InlineData(" multiple spaces here ", 3)]
+ [InlineData(null, 0)]
+ [InlineData("", 0)]
+ [InlineData(" ", 0)]
+ public void WordCount_ReturnsExpected(string? input, int expected)
+ {
+ Assert.Equal(expected, input.WordCount());
+ }
+
+ [Theory]
+ [InlineData("world", "/", "/world")]
+ [InlineData("/world", "/", "/world")]
+ [InlineData(null, "/", "/")]
+ [InlineData("", "/", "/")]
+ public void EnsureStartsWith_ReturnsExpected(string? input, string prefix, string expected)
+ {
+ Assert.Equal(expected, input.EnsureStartsWith(prefix));
+ }
+
+ [Theory]
+ [InlineData("hello", "/", "hello/")]
+ [InlineData("hello/", "/", "hello/")]
+ [InlineData(null, "/", "/")]
+ [InlineData("", "/", "/")]
+ public void EnsureEndsWith_ReturnsExpected(string? input, string suffix, string expected)
+ {
+ Assert.Equal(expected, input.EnsureEndsWith(suffix));
+ }
+
#endregion
}
diff --git a/OpenCode.Tests/StringExtension/StringExtension.Guid.UnitTest.cs b/OpenCode.Tests/StringExtension/StringExtension.Guid.UnitTest.cs
index def10cd..21a6d8f 100644
--- a/OpenCode.Tests/StringExtension/StringExtension.Guid.UnitTest.cs
+++ b/OpenCode.Tests/StringExtension/StringExtension.Guid.UnitTest.cs
@@ -92,4 +92,31 @@ public void IsValidGuid_Null_ReturnsFalse()
}
#endregion
+
+ #region --- FromShortGuid ---
+
+ [Fact]
+ public void FromShortGuid_RoundTrips()
+ {
+ var original = Guid.NewGuid();
+ var shortGuid = original.ToShortGuid();
+ var result = shortGuid.FromShortGuid();
+ Assert.Equal(original, result);
+ }
+
+ [Fact]
+ public void FromShortGuid_InvalidInput_ReturnsDefault()
+ {
+ Assert.Equal(Guid.Empty, "invalid".FromShortGuid());
+ }
+
+ [Fact]
+ public void FromShortGuid_Null_ReturnsDefault()
+ {
+ string? input = null;
+ var fallback = Guid.NewGuid();
+ Assert.Equal(fallback, input.FromShortGuid(fallback));
+ }
+
+ #endregion
}
diff --git a/OpenCode.Tests/StringExtension/StringExtension.Hash.UnitTest.cs b/OpenCode.Tests/StringExtension/StringExtension.Hash.UnitTest.cs
index 35e2f6d..3c0bbd2 100644
--- a/OpenCode.Tests/StringExtension/StringExtension.Hash.UnitTest.cs
+++ b/OpenCode.Tests/StringExtension/StringExtension.Hash.UnitTest.cs
@@ -13,8 +13,8 @@ public class StringExtensionHashTests
public void ToSHA256Hash_ValidString_ReturnsExpected()
{
string input = "Hello World";
- var expectedBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
- var expected = Convert.ToBase64String(expectedBytes);
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
+ var expected = ToHexLower(hash);
Assert.Equal(expected, input.ToSHA256Hash());
}
@@ -22,12 +22,19 @@ public void ToSHA256Hash_ValidString_ReturnsExpected()
[Theory]
[InlineData(null)]
[InlineData("")]
- [InlineData(" ")]
- public void ToSHA256Hash_NullOrWhitespace_ReturnsEmpty(string? input)
+ public void ToSHA256Hash_NullOrEmpty_ReturnsEmpty(string? input)
{
Assert.Equal(string.Empty, input.ToSHA256Hash());
}
+ [Fact]
+ public void ToSHA256Hash_WhitespaceString_ReturnsHash()
+ {
+ string input = " ";
+ var result = input.ToSHA256Hash();
+ Assert.NotEqual(string.Empty, result);
+ }
+
#endregion
#region --- ToMD5Hash ---
diff --git a/OpenCode.Tests/StringExtension/StringExtension.Integer.UnitTest.cs b/OpenCode.Tests/StringExtension/StringExtension.Integer.UnitTest.cs
index 35380fb..2ed1017 100644
--- a/OpenCode.Tests/StringExtension/StringExtension.Integer.UnitTest.cs
+++ b/OpenCode.Tests/StringExtension/StringExtension.Integer.UnitTest.cs
@@ -62,6 +62,7 @@ public void ParseToLong_UsesCustomDefaultValue()
[InlineData("abc", false)]
[InlineData(null, false)]
[InlineData("", false)]
+ [InlineData(" 123 ", true)]
public void IsInteger_ReturnsExpected(string? input, bool expected)
{
Assert.Equal(expected, input.IsInteger());
diff --git a/OpenCode/DateTimeExtension/DateTimeExtension.Core.cs b/OpenCode/DateTimeExtension/DateTimeExtension.Core.cs
index d440e57..367b340 100644
--- a/OpenCode/DateTimeExtension/DateTimeExtension.Core.cs
+++ b/OpenCode/DateTimeExtension/DateTimeExtension.Core.cs
@@ -102,4 +102,71 @@ public static int ToAge(this DateTime birthDate)
if (birthDate.Date > today.AddYears(-age)) age--;
return age;
}
+
+ ///
+ /// Determines whether the specified falls on a weekday (Monday through Friday).
+ ///
+ /// The date to evaluate.
+ /// true if is Monday through Friday; otherwise false.
+ public static bool IsWeekday(this DateTime date)
+ => !date.IsWeekend();
+
+ ///
+ /// Determines whether the specified falls within the inclusive range [start, end].
+ ///
+ /// The date to evaluate.
+ /// The start of the range (inclusive).
+ /// The end of the range (inclusive).
+ /// true if is between and inclusive; otherwise false.
+ public static bool IsBetween(this DateTime date, DateTime start, DateTime end)
+ => date >= start && date <= end;
+
+ ///
+ /// Converts the specified to a Unix timestamp (seconds since 1970-01-01 UTC).
+ ///
+ /// The date to convert.
+ /// The number of seconds elapsed since the Unix epoch.
+ public static long ToUnixTimestamp(this DateTime date)
+ {
+ var dto = date.Kind == DateTimeKind.Unspecified
+ ? new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc))
+ : new DateTimeOffset(date);
+ return dto.ToUnixTimeSeconds();
+ }
+
+ ///
+ /// Returns the first day of the month at midnight for the specified .
+ ///
+ /// The date for which to obtain the start of month.
+ /// A representing the first day of the month at 00:00:00.
+ public static DateTime StartOfMonth(this DateTime date)
+ => new(date.Year, date.Month, 1);
+
+ ///
+ /// Returns the last day of the month at 23:59:59.999 for the specified .
+ ///
+ /// The date for which to obtain the end of month.
+ /// A representing the last day of the month at end of day.
+ public static DateTime EndOfMonth(this DateTime date)
+ => new DateTime(date.Year, date.Month, DateTime.DaysInMonth(date.Year, date.Month), 23, 59, 59, 999);
+
+ ///
+ /// Returns the start of the week (midnight) for the specified .
+ ///
+ /// The date for which to obtain the start of week.
+ /// The day considered the start of the week. Defaults to .
+ /// A representing the start of the week at midnight.
+ public static DateTime StartOfWeek(this DateTime date, DayOfWeek startOfWeek = DayOfWeek.Monday)
+ {
+ int diff = ((int)date.DayOfWeek - (int)startOfWeek + 7) % 7;
+ return date.AddDays(-diff).Date;
+ }
+
+ ///
+ /// Returns the number of days in the month for the specified .
+ ///
+ /// The date whose month to query.
+ /// The number of days in the month (28–31).
+ public static int DaysInMonth(this DateTime date)
+ => DateTime.DaysInMonth(date.Year, date.Month);
}
diff --git a/OpenCode/DecimalExtension/DecimalExtension.cs.Core.cs b/OpenCode/DecimalExtension/DecimalExtension.cs.Core.cs
index cdc44fa..37168ac 100644
--- a/OpenCode/DecimalExtension/DecimalExtension.cs.Core.cs
+++ b/OpenCode/DecimalExtension/DecimalExtension.cs.Core.cs
@@ -36,5 +36,19 @@ public static string ToPercentage(this decimal value, int decimals = 0)
/// Converts decimal to currency string (e.g., "$12.34").
///
public static string ToCurrency(this decimal value, CultureInfo? culture = null)
- => string.Format(culture ?? CultureInfo.CurrentCulture, "{0:C}", value);
+ => string.Format(culture ?? CultureInfo.InvariantCulture, "{0:C}", value);
+
+ ///
+ /// Determines whether the specified decimal value is strictly greater than zero.
+ ///
+ /// The decimal to evaluate.
+ /// true if is greater than zero; otherwise false.
+ public static bool IsPositive(this decimal value) => value > 0m;
+
+ ///
+ /// Determines whether the specified decimal value is strictly less than zero.
+ ///
+ /// The decimal to evaluate.
+ /// true if is less than zero; otherwise false.
+ public static bool IsNegative(this decimal value) => value < 0m;
}
diff --git a/OpenCode/IntegerExtension/IntegerExtension.Core.cs b/OpenCode/IntegerExtension/IntegerExtension.Core.cs
index 3d730ea..2330f00 100644
--- a/OpenCode/IntegerExtension/IntegerExtension.Core.cs
+++ b/OpenCode/IntegerExtension/IntegerExtension.Core.cs
@@ -83,4 +83,28 @@ public static int Clamp(this int value, int min, int max)
/// The integer whose absolute value is to be returned.
/// The absolute value of .
public static int Abs(this int value) => Math.Abs(value);
+
+ ///
+ /// Determines whether the specified value falls within the inclusive range [min, max].
+ ///
+ /// The integer to evaluate.
+ /// The inclusive minimum value.
+ /// The inclusive maximum value.
+ /// true if is between and inclusive; otherwise false.
+ public static bool IsBetween(this int value, int min, int max)
+ => value >= min && value <= max;
+
+ ///
+ /// Determines whether the specified value is strictly greater than zero.
+ ///
+ /// The integer to evaluate.
+ /// true if is greater than zero; otherwise false.
+ public static bool IsPositive(this int value) => value > 0;
+
+ ///
+ /// Determines whether the specified value is strictly less than zero.
+ ///
+ /// The integer to evaluate.
+ /// true if is less than zero; otherwise false.
+ public static bool IsNegative(this int value) => value < 0;
}
diff --git a/OpenCode/OpenCode.csproj b/OpenCode/OpenCode.csproj
index a964dbd..a63ef02 100644
--- a/OpenCode/OpenCode.csproj
+++ b/OpenCode/OpenCode.csproj
@@ -1,10 +1,10 @@
- net8.0;net9.0
+ net8.0;net9.0;net10.0
- 3.2.0
+ 4.0.0
OpenCode
Navneet Hegde
Navneet Hegde
diff --git a/OpenCode/StringExtension/StringExtension.Core.cs b/OpenCode/StringExtension/StringExtension.Core.cs
index 960987c..aa531d3 100644
--- a/OpenCode/StringExtension/StringExtension.Core.cs
+++ b/OpenCode/StringExtension/StringExtension.Core.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@@ -136,9 +137,13 @@ public static string ToCamelCase(this string? input)
[GeneratedRegex(@"[\s\-]+")]
private static partial Regex SpaceOrDashRegex();
+
+[GeneratedRegex(@"[\s_]+")]
+private static partial Regex SpaceOrUnderscoreRegex();
#else
private static readonly Regex CamelCaseRegex = new(@"([a-z0-9])([A-Z])", RegexOptions.Compiled);
private static readonly Regex SpaceOrDashRegex = new(@"[\s\-]+", RegexOptions.Compiled);
+ private static readonly Regex SpaceOrUnderscoreRegex = new(@"[\s_]+", RegexOptions.Compiled);
#endif
///
@@ -171,8 +176,14 @@ public static string ToSnakeCase(this string? input)
public static string ToKebabCase(this string? input)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
- var result = Regex.Replace(input, @"([a-z0-9])([A-Z])", "$1-$2");
- result = Regex.Replace(result, @"[\s_]+", "-");
+
+#if NET7_0_OR_GREATER
+ var result = CamelCaseRegex().Replace(input, "$1-$2");
+ result = SpaceOrUnderscoreRegex().Replace(result, "-");
+#else
+ var result = CamelCaseRegex.Replace(input, "$1-$2");
+ result = SpaceOrUnderscoreRegex.Replace(result, "-");
+#endif
return result.ToLowerInvariant();
}
@@ -288,7 +299,7 @@ public static string ToSlug(this string? input)
///
/// The input string to validate as email.
/// True if the input matches a basic email pattern; false otherwise. Null input is treated as empty string.
- public static bool IsEmail(this string input)
+ public static bool IsEmail(this string? input)
{
return System.Text.RegularExpressions.Regex.IsMatch(
input ?? "",
@@ -301,7 +312,7 @@ public static bool IsEmail(this string input)
///
/// The input string to check.
/// True if input is a non-empty trimmed string that starts with '{' and ends with '}' or starts with '[' and ends with ']'. Otherwise false.
- public static bool IsJson(this string input)
+ public static bool IsJson(this string? input)
{
if (string.IsNullOrWhiteSpace(input))
return false;
@@ -311,5 +322,94 @@ public static bool IsJson(this string input)
(input.StartsWith('[') && input.EndsWith(']'));
}
+ ///
+ /// Reverses the characters in the string, correctly handling Unicode surrogate pairs.
+ ///
+ /// The input string to reverse.
+ /// Reversed string. Returns empty string when input is null or empty.
+ public static string Reverse(this string? input)
+ {
+ if (string.IsNullOrEmpty(input)) return string.Empty;
+ var elements = System.Globalization.StringInfo.GetTextElementEnumerator(input);
+ var result = new List();
+ while (elements.MoveNext())
+ result.Add(elements.GetTextElement());
+ result.Reverse();
+ return string.Concat(result);
+ }
+
+ ///
+ /// Determines whether the string contains the specified value using case-insensitive comparison.
+ ///
+ /// The input string to search within.
+ /// The value to search for.
+ /// True if contains using ordinal case-insensitive comparison; otherwise false.
+ public static bool ContainsIgnoreCase(this string? input, string? value)
+ {
+ if (input is null || value is null) return false;
+ return input.Contains(value, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Returns the leftmost N characters of the string.
+ ///
+ /// The input string.
+ /// Number of characters to return from the start.
+ /// The first characters, or the full string if shorter. Returns empty string when input is null or empty.
+ public static string Left(this string? input, int length)
+ {
+ if (string.IsNullOrEmpty(input) || length <= 0) return string.Empty;
+ return length >= input.Length ? input : input[..length];
+ }
+
+ ///
+ /// Returns the rightmost N characters of the string.
+ ///
+ /// The input string.
+ /// Number of characters to return from the end.
+ /// The last characters, or the full string if shorter. Returns empty string when input is null or empty.
+ public static string Right(this string? input, int length)
+ {
+ if (string.IsNullOrEmpty(input) || length <= 0) return string.Empty;
+ return length >= input.Length ? input : input[^length..];
+ }
+
+ ///
+ /// Counts the number of words in the string by splitting on whitespace.
+ ///
+ /// The input string.
+ /// Number of words. Returns 0 for null, empty, or whitespace-only input.
+ public static int WordCount(this string? input)
+ {
+ if (string.IsNullOrWhiteSpace(input)) return 0;
+ return input.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
+ }
+
+ ///
+ /// Ensures the string starts with the specified prefix. Prepends it if missing.
+ ///
+ /// The input string.
+ /// The prefix to ensure.
+ /// The string comparison type. Defaults to .
+ /// The string guaranteed to start with . Returns when input is null or empty.
+ public static string EnsureStartsWith(this string? input, string prefix, StringComparison comparison = StringComparison.Ordinal)
+ {
+ if (string.IsNullOrEmpty(input)) return prefix;
+ return input.StartsWith(prefix, comparison) ? input : prefix + input;
+ }
+
+ ///
+ /// Ensures the string ends with the specified suffix. Appends it if missing.
+ ///
+ /// The input string.
+ /// The suffix to ensure.
+ /// The string comparison type. Defaults to .
+ /// The string guaranteed to end with . Returns when input is null or empty.
+ public static string EnsureEndsWith(this string? input, string suffix, StringComparison comparison = StringComparison.Ordinal)
+ {
+ if (string.IsNullOrEmpty(input)) return suffix;
+ return input.EndsWith(suffix, comparison) ? input : input + suffix;
+ }
+
#endregion
}
diff --git a/OpenCode/StringExtension/StringExtension.Guid.cs b/OpenCode/StringExtension/StringExtension.Guid.cs
index a9c4454..90c0eee 100644
--- a/OpenCode/StringExtension/StringExtension.Guid.cs
+++ b/OpenCode/StringExtension/StringExtension.Guid.cs
@@ -63,4 +63,27 @@ public static bool IsGuid(this string? input)
///
public static bool IsValidGuid(this string? input)
=> Guid.TryParse(input, out var g) && g != Guid.Empty;
+
+ ///
+ /// Decodes a 22-character URL-safe Base64 string (produced by ) back to a .
+ ///
+ /// The short GUID string to decode. Must be exactly 22 characters.
+ /// The value to return when decoding fails. Defaults to .
+ /// The decoded , or when input is invalid.
+ public static Guid FromShortGuid(this string? input, Guid defaultValue = default)
+ {
+ if (string.IsNullOrEmpty(input) || input.Length != 22)
+ return defaultValue;
+
+ try
+ {
+ var base64 = input.Replace("_", "/").Replace("-", "+") + "==";
+ var bytes = Convert.FromBase64String(base64);
+ return new Guid(bytes);
+ }
+ catch (FormatException)
+ {
+ return defaultValue;
+ }
+ }
}
diff --git a/OpenCode/StringExtension/StringExtension.Hash.cs b/OpenCode/StringExtension/StringExtension.Hash.cs
index 8bb2cab..eddaa13 100644
--- a/OpenCode/StringExtension/StringExtension.Hash.cs
+++ b/OpenCode/StringExtension/StringExtension.Hash.cs
@@ -38,18 +38,25 @@ public static string ToMD5Hash(this string? input)
}
///
- /// Computes the SHA-256 hash of the provided string and returns it as a Base64-encoded string.
+ /// Computes the SHA-256 hash of the provided string and returns a lowercase hexadecimal representation.
///
- /// The input string to hash. If null or whitespace, an empty string is returned.
- /// Base64 encoded SHA-256 hash, or empty string when input is null or whitespace.
+ /// The input string to hash. If null or empty, an empty string is returned.
+ /// Lowercase hexadecimal string of the SHA-256 hash, or empty string when input is null or empty.
public static string ToSHA256Hash(this string? input)
{
- if (string.IsNullOrWhiteSpace(input))
+ if (string.IsNullOrEmpty(input))
return string.Empty;
- var bytes = Encoding.UTF8.GetBytes(input);
- var hash = SHA256.HashData(bytes);
- return Convert.ToBase64String(hash);
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
+
+#if NET8_0_OR_GREATER
+ return Convert.ToHexString(hash).ToLowerInvariant();
+#else
+ var sb = new StringBuilder(hash.Length * 2);
+ foreach (var b in hash)
+ sb.Append(b.ToString("x2"));
+ return sb.ToString();
+#endif
}
///
@@ -81,7 +88,7 @@ public static string FromBase64(this string? input)
var bytes = Convert.FromBase64String(input);
return Encoding.UTF8.GetString(bytes);
}
- catch
+ catch (FormatException)
{
// If input is not valid Base64, return it unchanged
return input;
diff --git a/OpenCode/StringExtension/StringExtension.Integer.cs b/OpenCode/StringExtension/StringExtension.Integer.cs
index a675091..f3a67ff 100644
--- a/OpenCode/StringExtension/StringExtension.Integer.cs
+++ b/OpenCode/StringExtension/StringExtension.Integer.cs
@@ -57,11 +57,10 @@ public static long ParseToLong(this string? input, long defaultValue = default)
/// and ; otherwise false.
///
///
- /// This method does not trim the input. If you expect surrounding whitespace, call
- /// input?.Trim() before invoking or use for safe parsing with trimming.
+ /// The method trims the input before attempting to parse, consistent with .
///
public static bool IsInteger(this string? input)
- => int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out _);
+ => int.TryParse(input?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out _);
///
/// Converts a numeric string to its ordinal representation (for example, "1" -> "1st").
diff --git a/README.md b/README.md
index ac8b593..0475fba 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
# OpenCode
-**OpenCode** is a lightweight, cross-targeted .NET library providing useful extension methods for strings, numbers, booleans, GUIDs, and dates. It simplifies common tasks such as safe trimming, casing conversions, masking, parsing, numeric formatting, hashing, and more. The library targets **.NET 8 through .NET 9**.
+**OpenCode** is a lightweight, cross-targeted .NET library providing useful extension methods for strings, numbers, booleans, GUIDs, and dates. It simplifies common tasks such as safe trimming, casing conversions, masking, parsing, numeric formatting, hashing, and more. The library targets **.NET 8 through .NET 10**.
---
## Supported Targets
-- .NET 8, .NET 9
+- .NET 8, .NET 9, .NET 10
---
@@ -19,8 +19,13 @@
- **`SafeTrim`** — trims input safely; returns empty string if input is `null`.
- **`Truncate`** — safely truncates a string to a maximum length.
- **`SafeSubstring`** — extracts substring without throwing exceptions for out-of-range indices.
-- **`EqualsIgnoreCase`** — ordinal case-insensitive comparison.
-- **`IsNumeric`** — checks if a string can be parsed as a number (decimal).
+- **`EqualsIgnoreCase`** — ordinal case-insensitive comparison.
+- **`ContainsIgnoreCase`** — case-insensitive contains check.
+- **`IsNumeric`** — checks if a string can be parsed as a number (decimal).
+- **`Reverse`** — reverses a string, handling Unicode surrogate pairs.
+- **`Left`**, **`Right`** — extract leftmost/rightmost N characters safely.
+- **`WordCount`** — counts words by splitting on whitespace.
+- **`EnsureStartsWith`**, **`EnsureEndsWith`** — prepend/append prefix or suffix if missing.
### Formatting Helpers
@@ -43,7 +48,9 @@
- **`ParseToDecimal`**, **`ParseToInt`**, **`ParseToLong`** — safe parsing with configurable defaults (invariant culture).
- **`RemoveTrailingZero`** — removes redundant trailing zeros (uses `G29` to preserve significance).
- **`IsDecimal`**, **`IsInteger`** — validate numeric strings.
-- **`ToOrdinal`** — converts numeric string to English ordinal (e.g., `1` → `1st`).
+- **`ToOrdinal`** — converts numeric string to English ordinal (e.g., `1` → `1st`).
+- **`IsBetween`** — check if an integer falls within an inclusive range.
+- **`IsPositive`**, **`IsNegative`** — check sign of integer or decimal values.
### Boolean Helpers
@@ -54,19 +61,26 @@
### Hashing & Encoding
- **`ToMD5Hash`** — computes MD5 hash (lowercase hex).
-- **`ToSHA256Hash`** — computes SHA256 hash (Base64).
+- **`ToSHA256Hash`** — computes SHA256 hash (lowercase hex).
- **`ToBase64`** / **`FromBase64`** — encode/decode Base64 strings safely.
### Guid Helpers
- **`ParseToGuid`**, **`IsGuid`**, **`IsValidGuid`** — parse and validate GUIDs.
-- **`ToShortGuid`**, **`ToCompactString`** — compact representations of GUIDs.
+- **`ToShortGuid`**, **`ToCompactString`** — compact representations of GUIDs.
+- **`FromShortGuid`** — decode a short GUID string back to a `Guid`.
### Date & Time Helpers
-- **`ParseToDateTime`**, **`ParseToDateOnly`** — safe parsing with default values.
-- **`IsDateTime`** — check if string can be parsed to DateTime.
-- **`ToFormattedDate`** — format parsed DateTime string.
+- **`ParseToDateTime`**, **`ParseToDateOnly`** — safe parsing with default values.
+- **`IsDateTime`** — check if string can be parsed to DateTime.
+- **`ToFormattedDate`** — format parsed DateTime string.
+- **`IsWeekday`** — check if a date is Monday through Friday.
+- **`IsBetween`** — check if a date falls within an inclusive range.
+- **`ToUnixTimestamp`** — convert DateTime to Unix epoch seconds.
+- **`StartOfMonth`**, **`EndOfMonth`** — get first/last moment of the month.
+- **`StartOfWeek`** — get the start of the week (configurable start day).
+- **`DaysInMonth`** — get the number of days in the date's month.
---
diff --git a/docs/upgrade-to-net10-plan.md b/docs/upgrade-to-net10-plan.md
new file mode 100644
index 0000000..65f2e4c
--- /dev/null
+++ b/docs/upgrade-to-net10-plan.md
@@ -0,0 +1,45 @@
+# Plan: Update OpenCode to .NET 10
+
+## Context
+
+OpenCode currently targets .NET 8 and .NET 9 (version 3.2.0). This update adds .NET 10 as a supported target framework and bumps the package version to 3.3.0. All three targets (net8.0, net9.0, net10.0) will be kept. Existing conditional compilation directives (`NET7_0_OR_GREATER`, `NET8_0_OR_GREATER`) automatically apply to .NET 10 — no source code changes needed.
+
+## Changes
+
+### 1. Update library project
+**File:** `OpenCode/OpenCode.csproj`
+- Line 4: `net8.0;net9.0` → `net8.0;net9.0;net10.0`
+- Line 7: `3.2.0` → `3.3.0`
+
+### 2. Update test project
+**File:** `OpenCode.Tests/OpenCode.Tests.csproj`
+- Line 4: `net8.0;net9.0` → `net8.0;net9.0;net10.0`
+
+### 3. Update CI/CD pipeline
+**File:** `.github/workflows/nuget-publish.yml`
+- Line 17: `dotnet-version: [8.x, 9.x]` → `dotnet-version: [8.x, 9.x, 10.x]`
+- Lines 37, 41, 48: Change `if: matrix.dotnet-version == '9.x'` → `if: matrix.dotnet-version == '10.x'` (pack/publish from latest SDK)
+
+### 4. Update README
+**File:** `README.md`
+- Line 3: "targets **.NET 8 through .NET 9**" → "targets **.NET 8 through .NET 10**"
+- Line 9: "- .NET 8, .NET 9" → "- .NET 8, .NET 9, .NET 10"
+
+### 5. Update CLAUDE.md
+**File:** `CLAUDE.md`
+- Line 7: "targeting .NET 8 and .NET 9" → "targeting .NET 8, .NET 9, and .NET 10"
+
+### 6. Update CHANGELOG
+**File:** `CHANGELOG.md`
+- Add a new `[3.3.0]` section at the top documenting the addition of .NET 10 support
+
+## No source code changes needed
+- `StringExtension.Core.cs` — `#if NET7_0_OR_GREATER` covers .NET 10
+- `StringExtension.Hash.cs` — `#if NET8_0_OR_GREATER` covers .NET 10
+
+## Verification
+```bash
+dotnet build # builds all 3 TFMs
+dotnet test # tests pass on all 3 TFMs
+dotnet pack --configuration Release --output ./nupkg # package includes all targets
+```