Skip to content

Commit 18e4d95

Browse files
Modernize library: target net8.0/net10.0, source-gen regex, records, immutability (#131)
* Modernize library: target net8.0/net10.0, source-gen regex, records, immutability - Drop netstandard2.0, target net8.0 and net10.0 LTS - Convert all Regex fields to [GeneratedRegex] on partial methods - Convert BusinessDay and DateTimeRange to record types - Convert AgeSpan to readonly record struct - Embrace immutability: IReadOnlyList, FrozenDictionary, get/init properties - Enable nullable reference types and implicit usings - Use String./Int32./Char. for static method calls per project convention - Use ReadOnlySpan<char> in TimeUnit and Helper for zero-alloc parsing - Restore all contextual comments stripped during modernization - Preserve public API: restore protected virtual Validate on BusinessWeek - Upgrade test project to xUnit v3 with Microsoft Testing Platform Co-authored-by: Cursor <cursoragent@cursor.com> * Fix CI build: suppress test-only nullable warnings and entry point conflict Co-authored-by: Cursor <cursoragent@cursor.com> * Fix CI: register GitHubActionsTestLogger MTP extension and address PR feedback - Explicitly register GitHubActionsTestLogger as a TestingPlatformBuilderHook to fix --report-github Unknown option error in CI (the packages build props do not flow correctly with single TargetFramework projects) - Remove redundant RegexOptions.Compiled from all [GeneratedRegex] attributes (source generator already compiles at build time) - Use pattern matching null checks (is null) instead of == null - Wrap List<T> assignments with .AsReadOnly() for true immutability on IReadOnlyList<T> properties in ComparisonFormatParser and TwoPartFormatParser Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3552051 commit 18e4d95

64 files changed

Lines changed: 484 additions & 639 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Exceptionless.DateTimeExtensions provides date/time utilities for .NET applicati
1616
- **DateTimeOffset Extensions** - Mirror of DateTime extensions for DateTimeOffset with offset-aware operations
1717
- **TimeSpan Extensions** - Human-readable formatting (`ToWords`), year/month extraction, rounding, `AgeSpan` struct
1818

19-
Design principles: **parse flexibility**, **timezone correctness**, **comprehensive edge case handling**, **netstandard2.0 compatibility**.
19+
Design principles: **parse flexibility**, **timezone correctness**, **comprehensive edge case handling**, **modern .NET features** (targeting net8.0/net10.0).
2020

2121
## Quick Start
2222

@@ -111,14 +111,24 @@ The library uses a **priority-ordered parser chain** for `DateTimeRange.Parse()`
111111
### Code Quality
112112

113113
- Write complete, runnable code—no placeholders, TODOs, or `// existing code...` comments
114-
- Use modern C# features compatible with **netstandard2.0**: be mindful of API availability
114+
- Use modern C# features available in **net8.0/net10.0**
115+
- **Nullable reference types** are enabled—annotate nullability correctly, don't suppress warnings without justification
116+
- **ImplicitUsings** are enabled—don't add `using System;`, `using System.Collections.Generic;`, etc.
115117
- Follow SOLID, DRY principles; remove unused code and parameters
116118
- Clear, descriptive naming; prefer explicit over clever
117-
- Use `ConfigureAwait(false)` in library code (not in tests)
119+
120+
### Modern .NET Idioms
121+
122+
- **Source-generated regexes**: Use `[GeneratedRegex]` on `partial` methods instead of `new Regex(...)` or `RegexOptions.Compiled`
123+
- **Record types**: Use `record` for immutable data types; use `readonly record struct` for small value types
124+
- **Frozen collections**: Use `FrozenDictionary` / `FrozenSet` for immutable lookup collections initialized once
125+
- **Guard APIs**: Use `ArgumentNullException.ThrowIfNull()`, `ArgumentException.ThrowIfNullOrEmpty()`, `ArgumentOutOfRangeException.ThrowIfGreaterThan()` etc. instead of manual null/range checks
126+
- **Range/Index syntax**: Prefer `[..^N]`, `[start..end]` over `Substring` calls
127+
- **Pattern matching**: Use `is`, `switch` expressions, and property patterns where they improve clarity
118128

119129
### Parsing & Date Math Patterns
120130

121-
- **Regex-based parsing**: Part parsers expose a `Regex` property; format parsers use regex internally
131+
- **Source-generated regex parsing**: Part parsers expose regex via `[GeneratedRegex]` partial methods; format parsers use source-generated regex internally
122132
- **Priority ordering**: Lower `[Priority]` values run first—put more specific/common parsers earlier
123133
- **Timezone preservation**: When parsing dates with explicit timezones (`Z`, `+05:00`), always preserve the original offset
124134
- **Upper/lower limit rounding**: `/d` rounds to start of day for lower limits, end of day for upper limits
@@ -143,8 +153,9 @@ The library uses a **priority-ordered parser chain** for `DateTimeRange.Parse()`
143153
### Performance Considerations
144154

145155
- **Avoid allocations in hot paths**: Parsing methods are called frequently; minimize string allocations
146-
- **Compiled regex**: Use `RegexOptions.Compiled` for frequently-used patterns
147-
- **Span-based parsing**: Where compatible with netstandard2.0, prefer span-based approaches
156+
- **Source-generated regex**: Use `[GeneratedRegex]` for all regex patterns—generates optimized code at compile time, avoiding runtime compilation overhead
157+
- **Frozen collections**: Use `FrozenDictionary` / `FrozenSet` for lookup tables that are initialized once and read many times
158+
- **Span-based parsing**: Prefer `ReadOnlySpan<char>` and span-based approaches for parsing hot paths
148159
- **Profile before optimizing**: Don't guess—measure
149160
- **Cache parser instances**: Parsers are discovered once via reflection and reused
150161

@@ -202,7 +213,7 @@ Before marking work complete, verify:
202213

203214
### Error Handling
204215

205-
- **Validate inputs**: Check for null, empty strings, invalid ranges at method entry
216+
- **Validate inputs**: Use `ArgumentNullException.ThrowIfNull()`, `ArgumentException.ThrowIfNullOrEmpty()`, and `ArgumentOutOfRangeException.ThrowIfGreaterThan()` etc. at method entry
206217
- **Fail fast**: Throw exceptions immediately for invalid arguments (don't propagate bad data)
207218
- **Meaningful messages**: Include parameter names and expected values in exception messages
208219
- **TryParse pattern**: Always provide a `TryParse` alternative that returns `bool` instead of throwing
@@ -220,7 +231,7 @@ Tests are not just validation—they're **executable documentation** and **desig
220231

221232
### Framework
222233

223-
- **xUnit** as the primary testing framework
234+
- **xUnit v3** with **Microsoft Testing Platform** as the test runner
224235
- **Foundatio.Xunit** provides `TestWithLoggingBase` for test output logging
225236
- Follow [Microsoft unit testing best practices](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices)
226237

build/common.props

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<Project>
22

33
<PropertyGroup>
4-
<TargetFramework>netstandard2.0</TargetFramework>
4+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
57
<Product>Exceptionless DateTime Extensions</Product>
68
<Description>DateTimeRange and various DateTime and TimeSpan extension methods.</Description>
79
<PackageProjectUrl>https://github.com/exceptionless/Exceptionless.DateTimeExtensions</PackageProjectUrl>
@@ -12,7 +14,7 @@
1214

1315
<Copyright>Copyright © $([System.DateTime]::Now.ToString(yyyy)) Exceptionless. All rights reserved.</Copyright>
1416
<Authors>Exceptionless</Authors>
15-
<NoWarn>$(NoWarn);CS1591;NU1701</NoWarn>
17+
<NoWarn>$(NoWarn);CS1591</NoWarn>
1618
<WarningsAsErrors>true</WarningsAsErrors>
1719
<LangVersion>latest</LangVersion>
1820
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,54 @@
1-
using System;
21
using System.Diagnostics;
32

43
namespace Exceptionless.DateTimeExtensions;
54

65
/// <summary>
7-
/// A class defining a business day.
6+
/// A record defining a business day.
87
/// </summary>
98
[DebuggerDisplay("DayOfWeek={DayOfWeek}, StartTime={StartTime}, EndTime={EndTime}")]
10-
public class BusinessDay
9+
public record BusinessDay
1110
{
1211
/// <summary>
13-
/// Initializes a new instance of the <see cref="BusinessDay"/> class.
12+
/// Initializes a new instance of the <see cref="BusinessDay"/> record with default 9am–5pm hours.
1413
/// </summary>
1514
/// <param name="dayOfWeek">The day of week this business day represents.</param>
16-
public BusinessDay(DayOfWeek dayOfWeek)
17-
{
18-
StartTime = TimeSpan.FromHours(9); // 9am
19-
EndTime = TimeSpan.FromHours(17); // 5pm
20-
DayOfWeek = dayOfWeek;
21-
}
15+
public BusinessDay(DayOfWeek dayOfWeek) : this(dayOfWeek, TimeSpan.FromHours(9) /* 9am */, TimeSpan.FromHours(17) /* 5pm */) { }
2216

2317
/// <summary>
24-
/// Initializes a new instance of the <see cref="BusinessDay"/> class.
18+
/// Initializes a new instance of the <see cref="BusinessDay"/> record.
2519
/// </summary>
2620
/// <param name="dayOfWeek">The day of week this business day represents.</param>
2721
/// <param name="startTime">The start time of the business day.</param>
2822
/// <param name="endTime">The end time of the business day.</param>
2923
public BusinessDay(DayOfWeek dayOfWeek, TimeSpan startTime, TimeSpan endTime)
3024
{
31-
if (startTime.TotalDays >= 1)
32-
throw new ArgumentOutOfRangeException(nameof(startTime), startTime, "The startTime argument must be less then one day.");
33-
34-
if (endTime.TotalDays > 1)
35-
throw new ArgumentOutOfRangeException(nameof(endTime), endTime, "The endTime argument must be less then one day.");
36-
25+
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(startTime.TotalDays, 1.0, nameof(startTime));
26+
ArgumentOutOfRangeException.ThrowIfGreaterThan(endTime.TotalDays, 1.0, nameof(endTime));
3727
if (endTime <= startTime)
38-
throw new ArgumentOutOfRangeException(nameof(endTime), endTime, "The endTime argument must be greater then startTime.");
28+
throw new ArgumentOutOfRangeException(nameof(endTime), endTime, "The endTime argument must be greater than startTime.");
3929

4030
DayOfWeek = dayOfWeek;
4131
StartTime = startTime;
4232
EndTime = endTime;
4333
}
4434

4535
/// <summary>
46-
/// Gets the day of week this business day represents..
36+
/// Gets the day of week this business day represents.
4737
/// </summary>
4838
/// <value>The day of week.</value>
49-
public DayOfWeek DayOfWeek { get; private set; }
39+
public DayOfWeek DayOfWeek { get; init; }
5040

5141
/// <summary>
5242
/// Gets the start time of the business day.
5343
/// </summary>
5444
/// <value>The start time of the business day.</value>
55-
public TimeSpan StartTime { get; private set; }
45+
public TimeSpan StartTime { get; init; }
5646

5747
/// <summary>
5848
/// Gets the end time of the business day.
5949
/// </summary>
6050
/// <value>The end time of the business day.</value>
61-
public TimeSpan EndTime { get; private set; }
51+
public TimeSpan EndTime { get; init; }
6252

6353
/// <summary>
6454
/// Determines whether the specified date falls in the business day.
@@ -67,17 +57,6 @@ public BusinessDay(DayOfWeek dayOfWeek, TimeSpan startTime, TimeSpan endTime)
6757
/// <returns>
6858
/// <c>true</c> if the specified date falls in the business day; otherwise, <c>false</c>.
6959
/// </returns>
70-
public bool IsBusinessDay(DateTime date)
71-
{
72-
if (date.DayOfWeek != DayOfWeek)
73-
return false;
74-
75-
if (date.TimeOfDay < StartTime)
76-
return false;
77-
78-
if (date.TimeOfDay > EndTime)
79-
return false;
80-
81-
return true;
82-
}
60+
public bool IsBusinessDay(DateTime date) =>
61+
date.DayOfWeek == DayOfWeek && date.TimeOfDay >= StartTime && date.TimeOfDay <= EndTime;
8362
}

0 commit comments

Comments
 (0)