diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index e2a5bf3..e3e430f 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -31,6 +31,9 @@ public static class DateMath // Pre-compiled regex for operation parsing to avoid repeated compilation private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + // Pre-compiled regex for offset parsing to avoid repeated compilation + private static readonly Regex _offsetRegex = new(@"(Z|[+-]\d{2}:\d{2})$", RegexOptions.Compiled); + /// /// Parses a date math expression and returns the resulting DateTimeOffset. /// @@ -64,7 +67,9 @@ public static bool TryParse(string expression, DateTimeOffset relativeBaseTime, var match = Parser.Match(expression); if (!match.Success) - return false; + { + return TryParseFallbackDate(expression, relativeBaseTime.Offset, isUpperLimit, out result); + } return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result); } @@ -110,7 +115,9 @@ public static bool TryParse(string expression, TimeZoneInfo timeZone, bool isUpp var match = Parser.Match(expression); if (!match.Success) - return false; + { + return TryParseFallbackDate(expression, timeZone, isUpperLimit, out result); + } return TryParseFromMatch(match, timeZone, isUpperLimit, out result); } @@ -211,7 +218,54 @@ public static bool IsValidExpression(string expression) if (String.IsNullOrEmpty(expression)) return false; - return Parser.IsMatch(expression); + if (Parser.IsMatch(expression)) + return true; + + // Fallback: Check if it's a valid explicit date + return TryParseFallbackDate(expression, TimeZoneInfo.Local, false, out _); + } + + private static bool TryParseFallbackDate(string expression, TimeZoneInfo defaultTimeZone, bool isUpperLimit, out DateTimeOffset result) + { + return TryParseFallbackDateCore(expression, isUpperLimit, out result, defaultTimeZone.GetUtcOffset); + } + + private static bool TryParseFallbackDate(string expression, TimeSpan offset, bool isUpperLimit, out DateTimeOffset result) + { + return TryParseFallbackDateCore(expression, isUpperLimit, out result, _ => offset); + } + + private static bool TryParseFallbackDateCore(string expression, bool isUpperLimit, out DateTimeOffset result, Func offsetResolver) + { + result = default; + + if (_offsetRegex.IsMatch(expression) && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) + { + result = explicitDate; + + if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) + { + // If time is exactly midnight, and it's an upper limit, set to end of day + result = result.EndOfDay(); + } + + return true; + } + + if (DateTime.TryParse(expression, out DateTime dt)) + { + result = new DateTimeOffset(dt, offsetResolver(dt)); + + if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) + { + // If time is exactly midnight, and it's an upper limit, set to end of day + result = result.EndOfDay(); + } + + return true; + } + + return false; } /// diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 667f648..c424ea7 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -311,7 +311,6 @@ public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string express [InlineData(" ")] [InlineData("invalid")] [InlineData("now+1x")] // Invalid unit - [InlineData("2023-01-01")] // Missing || [InlineData("||+1d")] // Missing anchor [InlineData("now/x")] // Invalid rounding unit [InlineData("2023-13-01||")] // Invalid month @@ -344,6 +343,7 @@ public void Parse_NullExpression_ThrowsArgumentException() [InlineData("now")] [InlineData("now+1h")] [InlineData("now-1d/d")] + [InlineData("2023-06-15")] [InlineData("2023-06-15||")] [InlineData("2023-06-15||+1M/d")] [InlineData("2025-01-01T01:25:35Z||+3d/d")] @@ -368,7 +368,6 @@ public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string express [InlineData("")] [InlineData("invalid")] [InlineData("now+")] - [InlineData("2023-01-01")] // Missing || [InlineData("||+1d")] // Missing anchor [InlineData("2001.02.01||")] // Dotted format no longer supported [InlineData("now/d+1h")] // Rounding must be final operation @@ -398,9 +397,69 @@ public void TryParse_NullExpression_ReturnsFalse() Assert.Equal(default, result); } + [Fact] + public void TryParse_FallbackExplicitDate_AppliesBaseOffset() + { + const string expression = "2023-04-01"; + _logger.LogDebug("Testing TryParse fallback with expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + var expected = new DateTimeOffset(2023, 4, 1, 0, 0, 0, _baseTime.Offset); + Assert.Equal(expected, result); + } + + [Fact] + public void TryParse_FallbackExplicitDateUpperLimit_AdjustsToEndOfDay() + { + const string expression = "2023-07-10"; + _logger.LogDebug("Testing TryParse fallback upper limit with expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime); + + bool success = DateMath.TryParse(expression, _baseTime, true, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + var expected = new DateTimeOffset(2023, 7, 10, 23, 59, 59, 999, _baseTime.Offset); + Assert.Equal(expected, result); + } + + [Fact] + public void TryParse_FallbackExplicitDateWithTimezone_PreservesOffset() + { + const string expression = "2023-05-05T18:45:00-07:00"; + _logger.LogDebug("Testing TryParse fallback with explicit offset expression: '{Expression}'", expression); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.Equal(new DateTimeOffset(2023, 5, 5, 18, 45, 0, TimeSpan.FromHours(-7)), result); + } + + [Fact] + public void TryParse_FallbackExplicitDateWithTimeZoneInfo_UsesProvidedOffset() + { + const string expression = "2023-09-15"; + var customZone = TimeZoneInfo.CreateCustomTimeZone("TestPlusThree", TimeSpan.FromHours(3), "Test +3", "Test +3"); + _logger.LogDebug("Testing TryParse fallback with TimeZoneInfo: '{Expression}', TimeZone: {TimeZone}", expression, customZone); + + bool success = DateMath.TryParse(expression, customZone, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.Equal(new DateTimeOffset(2023, 9, 15, 0, 0, 0, customZone.BaseUtcOffset), result); + } + [Theory] [InlineData("now+1h", false)] [InlineData("now-1d/d", true)] + [InlineData("2023-06-15", false)] [InlineData("2023-06-15||+1M", false)] [InlineData("2025-01-01T01:25:35Z||+3d/d", true)] public void Parse_And_TryParse_ReturnSameResults(string expression, bool isUpperLimit) @@ -556,7 +615,7 @@ public void ParseTimeZone_Now_ReturnsCorrectTimezone(string timeZoneId, int expe public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() { var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); - const string expression = "2023-06-15T14:30:00||"; + const string expression = "2023-06-15T14:30:00"; _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", expression, easternTimeZone.Id); @@ -581,7 +640,7 @@ public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone() { var pacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Pacific"); - const string expression = "2023-06-15T14:30:00+05:00||"; // Explicit +05:00 timezone + const string expression = "2023-06-15T14:30:00+05:00"; // Explicit +05:00 timezone _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", expression, pacificTimeZone.Id);