diff --git a/.gitignore b/.gitignore
index 897f2f0..6bc9163 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,4 +51,5 @@ _NCrunch_*
# Rider auto-generates .iml files, and contentModel.xml
**/.idea/**/*.iml
**/.idea/**/contentModel.xml
-**/.idea/**/modules.xml
\ No newline at end of file
+**/.idea/**/modules.xml
+.idea/.idea.Exceptionless.DateTimeExtensions/.idea/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d5cdf52..ac7149a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,6 @@
{
"cSpell.words": [
+ "millis",
"timespan"
]
}
\ No newline at end of file
diff --git a/README.md b/README.md
index fc7d868..cc5ab43 100644
--- a/README.md
+++ b/README.md
@@ -34,15 +34,101 @@ bool isDay = day.IsBusinessDay(date);
### DateTime Ranges
-Quickly work with date ranges. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs) for more usage samples.
+Quickly work with date ranges with support for Elasticsearch-style date math expressions and bracket notation. Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs) for more usage samples.
```csharp
+// Basic range parsing
var range = DateTimeRange.Parse("yesterday", DateTime.Now);
if (range.Contains(DateTime.Now.Subtract(TimeSpan.FromHours(6)))) {
//...
}
+
+// Elasticsearch Date Math support with proper timezone handling
+var elasticRange = DateTimeRange.Parse("2025-01-01T01:25:35Z||+3d/d", DateTime.Now);
+// Supports timezone-aware operations: Z (UTC), +05:00, -08:00
+
+// Bracket notation support [start TO end]
+var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now);
+
+// Wildcard support for open-ended ranges
+var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // From date to infinity
```
+#### Date Math Features
+
+Supports full Elasticsearch date math syntax following [official specifications](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math):
+
+- **Anchors**: `now`, explicit dates with `||` separator
+- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round down)
+- **Units**: `y` (years), `M` (months), `w` (weeks), `d` (days), `h`/`H` (hours), `m` (minutes), `s` (seconds)
+- **Timezone Support**: Preserves explicit timezones (`Z`, `+05:00`, `-08:00`) or uses system timezone as fallback
+
+Examples:
+
+- `now+1h` - One hour from now
+- `now-1d/d` - Start of yesterday
+- `2025-01-01T01:25:35Z||+3d/d` - January 4th, 2025 (start of day) in UTC
+- `2023-06-15T14:30:00+05:00||+1M-2d` - One month minus 2 days from the specified date/time in +05:00 timezone
+
+### DateMath Utility
+
+For applications that need standalone date math parsing without the range functionality, the `DateMath` utility class provides direct access to Elasticsearch date math expression parsing. Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs) for more usage samples.
+
+```csharp
+using Exceptionless.DateTimeExtensions;
+
+// Parse date math expressions with standard .NET conventions
+var baseTime = DateTimeOffset.Now;
+
+// Parse method - throws ArgumentException on invalid input
+var result = DateMath.Parse("now+1h", baseTime);
+var rounded = DateMath.Parse("now-1d/d", baseTime, isUpperLimit: false); // Start of yesterday
+
+// TryParse method - returns bool for success/failure
+if (DateMath.TryParse("2023.06.15||+1M/d", baseTime, false, out var parsed)) {
+ // Successfully parsed: June 15, 2023 + 1 month, rounded to start of day
+ Console.WriteLine($"Parsed: {parsed:O}");
+}
+
+// Upper limit behavior affects rounding
+var startOfDay = DateMath.Parse("now/d", baseTime, isUpperLimit: false); // 00:00:00
+var endOfDay = DateMath.Parse("now/d", baseTime, isUpperLimit: true); // 23:59:59.999
+
+// Explicit dates with timezone preservation
+var utcResult = DateMath.Parse("2025-01-01T01:25:35Z||+3d/d", baseTime);
+var offsetResult = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", baseTime);
+```
+
+#### TimeZone-Aware DateMath
+
+The `DateMath` utility also provides overloads that work directly with `TimeZoneInfo` for better timezone handling:
+
+```csharp
+using Exceptionless.DateTimeExtensions;
+
+// Parse expressions using a specific timezone
+var utcTimeZone = TimeZoneInfo.Utc;
+var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern");
+
+// "now" will use current time in the specified timezone
+var utcResult = DateMath.Parse("now+1h", utcTimeZone);
+var easternResult = DateMath.Parse("now/d", easternTimeZone, isUpperLimit: false);
+
+// TryParse with timezone
+if (DateMath.TryParse("now+2d-3h", easternTimeZone, false, out var result)) {
+ Console.WriteLine($"Eastern time result: {result:O}");
+}
+
+// Dates without explicit timezone use the provided TimeZoneInfo
+var localDate = DateMath.Parse("2023-06-15T14:30:00||+1M", easternTimeZone);
+
+// Dates with explicit timezone are preserved regardless of TimeZoneInfo parameter
+var preservedTz = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", easternTimeZone);
+// Result will still have +05:00 offset, not Eastern time offset
+```
+
+The `DateMath` utility supports the same comprehensive syntax as `DateTimeRange` but provides a simpler API for direct parsing operations.
+
### TimeUnit
Quickly work with time units. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs) for more usage samples.
diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs
new file mode 100644
index 0000000..5f7f7ce
--- /dev/null
+++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs
@@ -0,0 +1,503 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace Exceptionless.DateTimeExtensions;
+
+///
+/// Provides Elasticsearch date math parsing functionality.
+/// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s).
+/// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d
+///
+/// Timezone Handling (following Elasticsearch standards):
+/// - Explicit timezone (Z, +05:00, -08:00): Preserved from input
+/// - No timezone: Uses current system timezone
+///
+/// References:
+/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math
+/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding
+///
+public static class DateMath
+{
+ // Match date math expressions with anchors and operations
+ internal static readonly Regex Parser = new(
+ @"^(?now|(?\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" +
+ @"(?(?:[+\-/]\d*[yMwdhHms])*)$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ // Pre-compiled regex for operation parsing to avoid repeated compilation
+ private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled);
+
+ ///
+ /// Parses a date math expression and returns the resulting DateTimeOffset.
+ ///
+ /// The date math expression to parse
+ /// The base time to use for relative calculations (e.g., 'now')
+ /// Whether this is for an upper limit (affects rounding behavior)
+ /// The parsed DateTimeOffset
+ /// Thrown when the expression is invalid or cannot be parsed
+ public static DateTimeOffset Parse(string expression, DateTimeOffset relativeBaseTime, bool isUpperLimit = false)
+ {
+ if (!TryParse(expression, relativeBaseTime, isUpperLimit, out DateTimeOffset result))
+ throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression));
+
+ return result;
+ }
+
+ ///
+ /// Tries to parse a date math expression and returns the resulting DateTimeOffset.
+ ///
+ /// The date math expression to parse
+ /// The base time to use for relative calculations (e.g., 'now')
+ /// Whether this is for an upper limit (affects rounding behavior)
+ /// The parsed DateTimeOffset if successful
+ /// True if parsing succeeded, false otherwise
+ public static bool TryParse(string expression, DateTimeOffset relativeBaseTime, bool isUpperLimit, out DateTimeOffset result)
+ {
+ result = default;
+
+ if (String.IsNullOrEmpty(expression))
+ return false;
+
+ var match = Parser.Match(expression);
+ if (!match.Success)
+ return false;
+
+ return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result);
+ }
+
+ ///
+ /// Parses a date math expression and returns the resulting DateTimeOffset using the specified timezone.
+ ///
+ /// The date math expression to parse
+ /// The timezone to use for 'now' calculations and dates without explicit timezone information
+ /// Whether this is for an upper limit (affects rounding behavior)
+ /// The parsed DateTimeOffset
+ /// Thrown when the expression is invalid or cannot be parsed
+ /// Thrown when timeZone is null
+ public static DateTimeOffset Parse(string expression, TimeZoneInfo timeZone, bool isUpperLimit = false)
+ {
+ if (timeZone == null)
+ throw new ArgumentNullException(nameof(timeZone));
+
+ if (!TryParse(expression, timeZone, isUpperLimit, out DateTimeOffset result))
+ throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression));
+
+ return result;
+ }
+
+ ///
+ /// Tries to parse a date math expression and returns the resulting DateTimeOffset using the specified timezone.
+ ///
+ /// The date math expression to parse
+ /// The timezone to use for 'now' calculations and dates without explicit timezone information
+ /// Whether this is for an upper limit (affects rounding behavior)
+ /// The parsed DateTimeOffset if successful
+ /// True if parsing succeeded, false otherwise
+ /// Thrown when timeZone is null
+ public static bool TryParse(string expression, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result)
+ {
+ if (timeZone == null)
+ throw new ArgumentNullException(nameof(timeZone));
+
+ result = default;
+
+ if (String.IsNullOrEmpty(expression))
+ return false;
+
+ var match = Parser.Match(expression);
+ if (!match.Success)
+ return false;
+
+ return TryParseFromMatch(match, timeZone, isUpperLimit, out result);
+ }
+
+ ///
+ /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset.
+ /// This method bypasses the regex matching for cases where the match is already available.
+ ///
+ /// The regex match containing the parsed expression groups
+ /// The base time to use for relative calculations (e.g., 'now')
+ /// Whether this is for an upper limit (affects rounding behavior)
+ /// The parsed DateTimeOffset if successful
+ /// True if parsing succeeded, false otherwise
+ public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit, out DateTimeOffset result)
+ {
+ result = default;
+
+ try
+ {
+ // Parse the anchor (now or explicit date)
+ DateTimeOffset baseTime;
+ string anchor = match.Groups["anchor"].Value;
+
+ if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
+ {
+ baseTime = relativeBaseTime;
+ }
+ else
+ {
+ // Parse explicit date from the date group
+ string dateStr = match.Groups["date"].Value;
+ if (!TryParseExplicitDate(dateStr, relativeBaseTime.Offset, out baseTime))
+ return false;
+ }
+
+ // Parse and apply operations
+ string operations = match.Groups["operations"].Value;
+ result = ApplyOperations(baseTime, operations, isUpperLimit);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset using the specified timezone.
+ /// This method bypasses the regex matching for cases where the match is already available.
+ ///
+ /// The regex match containing the parsed expression groups
+ /// The timezone to use for 'now' calculations and dates without explicit timezone information
+ /// Whether this is for an upper limit (affects rounding behavior)
+ /// The parsed DateTimeOffset if successful
+ /// True if parsing succeeded, false otherwise
+ public static bool TryParseFromMatch(Match match, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result)
+ {
+ result = default;
+
+ try
+ {
+ // Parse the anchor (now or explicit date)
+ DateTimeOffset baseTime;
+ string anchor = match.Groups["anchor"].Value;
+
+ if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
+ {
+ // Use current time in the specified timezone
+ baseTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone);
+ }
+ else
+ {
+ // Parse explicit date from the date group
+ string dateStr = match.Groups["date"].Value;
+ TimeSpan offset = timeZone.GetUtcOffset(DateTime.UtcNow);
+ if (!TryParseExplicitDate(dateStr, offset, out baseTime))
+ return false;
+ }
+
+ // Parse and apply operations
+ string operations = match.Groups["operations"].Value;
+ result = ApplyOperations(baseTime, operations, isUpperLimit);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Checks if the given expression is a valid date math expression.
+ ///
+ /// The expression to validate
+ /// True if the expression is valid date math, false otherwise
+ public static bool IsValidExpression(string expression)
+ {
+ if (String.IsNullOrEmpty(expression))
+ return false;
+
+ return Parser.IsMatch(expression);
+ }
+
+ ///
+ /// Attempts to parse an explicit date string with proper timezone handling.
+ /// Supports various Elasticsearch-compatible date formats with optional timezone information.
+ ///
+ /// Performance-optimized with length checks and format ordering by likelihood.
+ ///
+ /// Timezone Behavior:
+ /// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input
+ /// - If no timezone specified: Uses the provided fallback offset
+ ///
+ /// This matches Elasticsearch's behavior where explicit timezone information takes precedence.
+ ///
+ /// The date string to parse
+ /// Fallback timezone offset for dates without explicit timezone
+ /// The parsed DateTimeOffset with correct timezone
+ /// True if parsing succeeded, false otherwise
+ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result)
+ {
+ result = default;
+
+ if (String.IsNullOrEmpty(dateStr))
+ return false;
+
+ int len = dateStr.Length;
+
+ // Early exit for obviously invalid lengths
+ if (len is < 4 or > 29) // Min: yyyy (4), Max: yyyy-MM-ddTHH:mm:ss.fffzzz (29)
+ return false;
+
+ // Fast character validation for year digits
+ if (!Char.IsDigit(dateStr[0]) || !Char.IsDigit(dateStr[1]) ||
+ !Char.IsDigit(dateStr[2]) || !Char.IsDigit(dateStr[3]))
+ return false;
+
+ // Detect timezone presence for smart format selection
+ bool hasZ = dateStr[len - 1] == 'Z';
+ bool hasTimezone = hasZ;
+ if (!hasTimezone && len > 10) // Check for +/-HH:mm timezone format
+ {
+ for (int index = Math.Max(10, len - 6); index < len - 1; index++)
+ {
+ if (dateStr[index] is '+' or '-' && index + 1 < len && Char.IsDigit(dateStr[index + 1]))
+ {
+ hasTimezone = true;
+ break;
+ }
+ }
+ }
+
+ // Length-based format selection for maximum performance
+ // Only try formats that match the exact length to avoid unnecessary parsing attempts
+ switch (len)
+ {
+ case 4: // Built-in: year (yyyy)
+ return TryParseWithFormat(dateStr, "yyyy", offset, false, out result);
+
+ case 7: // Built-in: year_month (yyyy-MM)
+ if (dateStr[4] == '-')
+ return TryParseWithFormat(dateStr, "yyyy-MM", offset, false, out result);
+ break;
+
+ case 8: // Built-in: basic_date (yyyyMMdd)
+ return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result);
+
+ case 10: // Built-in: date (yyyy-MM-dd)
+ if (dateStr[4] == '-' && dateStr[7] == '-')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-dd", offset, false, out result);
+ }
+ break;
+
+ case 13: // Built-in: date_hour (yyyy-MM-ddTHH)
+ if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH", offset, false, out result);
+ }
+ break;
+
+ case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm)
+ if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm", offset, false, out result);
+ }
+ break;
+
+ case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss)
+ if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss", offset, false, out result);
+ }
+ break;
+
+ case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ)
+ if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ssZ", offset, true, out result);
+ }
+ break;
+
+ case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff)
+ if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fff", offset, false, out result);
+ }
+ break;
+
+ case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ)
+ if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.')
+ {
+ return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffZ", offset, true, out result);
+ }
+ break;
+ }
+
+ // Handle RFC 822 timezone offset formats (variable lengths: +05:00, +0500, etc.)
+ // Note: .NET uses 'zzz' pattern for timezone offsets like +05:00
+ if (hasTimezone && !hasZ)
+ {
+ // Only try timezone formats for lengths that make sense
+ if (len is >= 25 and <= 29) // +05:00 variants
+ {
+ if (dateStr.Contains(".")) // with milliseconds
+ {
+ // yyyy-MM-ddTHH:mm:ss.fff+05:00
+ if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffzzz", offset, true, out result))
+ return true;
+ }
+ }
+
+ if (len is >= 22 and <= 25) // without milliseconds
+ {
+ // yyyy-MM-ddTHH:mm:ss+05:00
+ if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:sszzz", offset, true, out result))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Helper method to parse with a specific format, handling timezone appropriately.
+ ///
+ private static bool TryParseWithFormat(string dateStr, string format, TimeSpan offset, bool hasTimezone, out DateTimeOffset result)
+ {
+ result = default;
+
+ if (hasTimezone)
+ {
+ // Try parsing with timezone information preserved
+ return DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out result);
+ }
+
+ // For formats without timezone, parse as DateTime and treat as if already in target timezone
+ if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out DateTime dateTime))
+ {
+ // Treat the parsed DateTime as if it's already in the target timezone
+ result = new DateTimeOffset(dateTime.Ticks, offset);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Applies date math operations to a base time.
+ ///
+ /// The base time to apply operations to
+ /// The operations string (e.g., "+1d-2h/d")
+ /// Whether this is for an upper limit (affects rounding)
+ /// The result after applying all operations
+ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit = false)
+ {
+ if (String.IsNullOrEmpty(operations))
+ return baseTime;
+
+ var result = baseTime;
+ var matches = _operationRegex.Matches(operations);
+
+ // Validate that all operations were matched properly
+ int totalMatchLength = matches.Cast().Sum(m => m.Length);
+ if (totalMatchLength != operations.Length)
+ {
+ // If not all operations were matched, there are invalid operations
+ throw new ArgumentException("Invalid operations");
+ }
+
+ // Validate that rounding operations (/) are only at the end
+ // According to Elasticsearch spec, rounding must be the final operation
+ bool foundRounding = false;
+ for (int i = 0; i < matches.Count; i++)
+ {
+ string operation = matches[i].Groups[1].Value;
+ if (operation == "/")
+ {
+ if (foundRounding)
+ {
+ // Multiple rounding operations are not allowed
+ throw new ArgumentException("Multiple rounding operations are not allowed");
+ }
+ if (i != matches.Count - 1)
+ {
+ // Rounding operation must be the last operation
+ throw new ArgumentException("Rounding operation must be the final operation");
+ }
+ foundRounding = true;
+ }
+ }
+
+ foreach (Match opMatch in matches)
+ {
+ string operation = opMatch.Groups[1].Value;
+ string amountStr = opMatch.Groups[2].Value;
+ string unit = opMatch.Groups[3].Value;
+
+ // Default amount is 1 if not specified
+ int amount = String.IsNullOrEmpty(amountStr) ? 1 : Int32.Parse(amountStr);
+
+ switch (operation)
+ {
+ case "+":
+ result = AddTimeUnit(result, amount, unit);
+ break;
+ case "-":
+ result = AddTimeUnit(result, -amount, unit);
+ break;
+ case "/":
+ result = RoundToUnit(result, unit, isUpperLimit);
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Adds a time unit to a DateTimeOffset.
+ ///
+ /// The base date time
+ /// The amount to add
+ /// The time unit (y, M, w, d, h, H, m, s)
+ /// The result after adding the time unit
+ public static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, string unit)
+ {
+ try
+ {
+ return unit switch
+ {
+ "y" => dateTime.AddYears(amount),
+ "M" => dateTime.AddMonths(amount), // Capital M for months
+ "m" => dateTime.AddMinutes(amount), // Lowercase m for minutes
+ "w" => dateTime.AddDays(amount * 7),
+ "d" => dateTime.AddDays(amount), // Only lowercase d for days
+ "h" or "H" => dateTime.AddHours(amount),
+ "s" => dateTime.AddSeconds(amount),
+ _ => throw new ArgumentException($"Invalid time unit: {unit}")
+ };
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ // Return original date if operation would overflow
+ return dateTime;
+ }
+ }
+
+ ///
+ /// Rounds a DateTimeOffset to a time unit.
+ ///
+ /// The date time to round
+ /// The time unit to round to (y, M, w, d, h, H, m, s)
+ /// Whether to round up (end of period) or down (start of period)
+ /// The rounded DateTimeOffset
+ public static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit = false)
+ {
+ return unit switch
+ {
+ "y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(),
+ "M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(),
+ "w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(),
+ "d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(), // Only lowercase d for days
+ "h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(),
+ "m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(),
+ "s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(),
+ _ => throw new ArgumentException($"Invalid time unit for rounding: {unit}")
+ };
+ }
+}
diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs
new file mode 100644
index 0000000..7840db4
--- /dev/null
+++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.RegularExpressions;
+
+namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
+
+///
+/// Parses Elasticsearch date math expressions using the DateMath utility class.
+/// This is a thin wrapper that adapts the DateMath utility for use within the format parser framework.
+///
+/// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s).
+/// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d
+///
+/// For more details about date math functionality, see .
+///
+[Priority(35)]
+public class DateMathPartParser : IPartParser
+{
+ public Regex Regex => DateMath.Parser;
+
+ public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
+ {
+ // Since we already have a successful regex match, use the efficient TryParseFromMatch method
+ // to avoid redundant regex matching and validation
+ return DateMath.TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out DateTimeOffset result)
+ ? result
+ : null;
+ }
+}
diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs
new file mode 100644
index 0000000..78a434d
--- /dev/null
+++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Text.RegularExpressions;
+
+namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
+
+[Priority(1)]
+public class WildcardPartParser : IPartParser
+{
+ private static readonly Regex _wildcardRegex = new(@"\G\s*\*(?=\s|\]|\}|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ public Regex Regex => _wildcardRegex;
+
+ public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
+ {
+ if (!match.Success)
+ return null;
+
+ return isUpperLimit ? DateTimeOffset.MaxValue : DateTimeOffset.MinValue;
+ }
+}
diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs
index 6445245..c20742a 100644
--- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs
+++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs
@@ -8,9 +8,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers;
[Priority(25)]
public class TwoPartFormatParser : IFormatParser
{
- private static readonly Regex _beginRegex = new(@"^\s*", RegexOptions.Compiled);
+ private static readonly Regex _beginRegex = new(@"^\s*([\[\{])?\s*", RegexOptions.Compiled);
private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
- private static readonly Regex _endRegex = new(@"\G\s*$", RegexOptions.Compiled);
+ private static readonly Regex _endRegex = new(@"\G\s*([\]\}])?\s*$", RegexOptions.Compiled);
public TwoPartFormatParser()
{
@@ -33,6 +33,9 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
if (!begin.Success)
return null;
+ // Capture the opening bracket if present
+ string openingBracket = begin.Groups[1].Value;
+
index += begin.Length;
DateTimeOffset? start = null;
foreach (var parser in Parsers)
@@ -70,9 +73,36 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
break;
}
- if (!_endRegex.IsMatch(content, index))
+ var endMatch = _endRegex.Match(content, index);
+ if (!endMatch.Success)
+ return null;
+
+ // Validate bracket matching
+ string closingBracket = endMatch.Groups[1].Value;
+ if (!IsValidBracketPair(openingBracket, closingBracket))
return null;
return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue);
}
+
+ ///
+ /// Validates that opening and closing brackets are properly matched.
+ ///
+ /// The opening bracket character
+ /// The closing bracket character
+ /// True if brackets are properly matched, false otherwise
+ private static bool IsValidBracketPair(string opening, string closing)
+ {
+ // Both empty - valid (no brackets)
+ if (String.IsNullOrEmpty(opening) && String.IsNullOrEmpty(closing))
+ return true;
+
+ // One empty, one not - invalid (unbalanced)
+ if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing))
+ return false;
+
+ // Check for proper matching pairs
+ return (opening == "[" && closing == "]") ||
+ (opening == "{" && closing == "}");
+ }
}
diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
new file mode 100644
index 0000000..866bcd2
--- /dev/null
+++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
@@ -0,0 +1,760 @@
+using System;
+using Foundatio.Xunit;
+using Microsoft.Extensions.Logging;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Exceptionless.DateTimeExtensions.Tests;
+
+///
+/// Comprehensive tests for the DateMath utility class, covering all parsing scenarios,
+/// edge cases, timezone handling, and error conditions.
+///
+public class DateMathTests : TestWithLoggingBase
+{
+ private readonly DateTimeOffset _baseTime = new(2023, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5));
+
+ public DateMathTests(ITestOutputHelper output) : base(output)
+ {
+ }
+
+ [Theory]
+ [InlineData("now", false)]
+ [InlineData("now", true)]
+ public void Parse_Now_ReturnsBaseTime(string expression, bool isUpperLimit)
+ {
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, IsUpperLimit: {IsUpperLimit}",
+ expression, _baseTime, isUpperLimit);
+
+ var result = DateMath.Parse(expression, _baseTime, isUpperLimit);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(_baseTime, result);
+ }
+
+ [Theory]
+ [InlineData("now+1h", 1)]
+ [InlineData("now+2h", 2)]
+ [InlineData("now+24h", 24)]
+ [InlineData("now+1H", 1)] // Case insensitive
+ [InlineData("now-1h", -1)]
+ [InlineData("now-12h", -12)]
+ public void Parse_HourOperations_ReturnsCorrectResult(string expression, int hours)
+ {
+ var expected = _baseTime.AddHours(hours);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now+1d", 1)]
+ [InlineData("now+7d", 7)]
+ [InlineData("now-1d", -1)]
+ [InlineData("now-30d", -30)]
+ public void Parse_DayOperations_ReturnsCorrectResult(string expression, int days)
+ {
+ var expected = _baseTime.AddDays(days);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now+1M", 1)]
+ [InlineData("now+6M", 6)]
+ [InlineData("now-1M", -1)]
+ [InlineData("now-12M", -12)]
+ public void Parse_MonthOperations_ReturnsCorrectResult(string expression, int months)
+ {
+ var expected = _baseTime.AddMonths(months);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now+1y", 1)]
+ [InlineData("now+5y", 5)]
+ [InlineData("now-1y", -1)]
+ [InlineData("now-10y", -10)]
+ public void Parse_YearOperations_ReturnsCorrectResult(string expression, int years)
+ {
+ var expected = _baseTime.AddYears(years);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now+1w", 7)]
+ [InlineData("now+2w", 14)]
+ [InlineData("now-1w", -7)]
+ [InlineData("now-4w", -28)]
+ public void Parse_WeekOperations_ReturnsCorrectResult(string expression, int days)
+ {
+ var expected = _baseTime.AddDays(days);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now+1m", 1)]
+ [InlineData("now+30m", 30)]
+ [InlineData("now-1m", -1)]
+ [InlineData("now-60m", -60)]
+ public void Parse_MinuteOperations_ReturnsCorrectResult(string expression, int minutes)
+ {
+ var expected = _baseTime.AddMinutes(minutes);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now+1s", 1)]
+ [InlineData("now+30s", 30)]
+ [InlineData("now-1s", -1)]
+ [InlineData("now-3600s", -3600)]
+ public void Parse_SecondOperations_ReturnsCorrectResult(string expression, int seconds)
+ {
+ var expected = _baseTime.AddSeconds(seconds);
+ _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}",
+ expression, _baseTime, expected);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("now/d", false)] // Start of day
+ [InlineData("now/d", true)] // End of day
+ [InlineData("now/h", false)] // Start of hour
+ [InlineData("now/h", true)] // End of hour
+ [InlineData("now/m", false)] // Start of minute
+ [InlineData("now/m", true)] // End of minute
+ public void Parse_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit)
+ {
+ _logger.LogDebug("Testing Parse with rounding expression: '{Expression}', BaseTime: {BaseTime}, IsUpperLimit: {IsUpperLimit}",
+ expression, _baseTime, isUpperLimit);
+
+ var result = DateMath.Parse(expression, _baseTime, isUpperLimit);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ if (expression.EndsWith("/d"))
+ {
+ if (isUpperLimit)
+ {
+ // End of day: 23:59:59.999
+ var expectedEnd = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, 23, 59, 59, 999, _baseTime.Offset);
+ Assert.Equal(expectedEnd, result);
+ }
+ else
+ {
+ // Start of day: 00:00:00.000
+ var expectedStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, 0, 0, 0, 0, _baseTime.Offset);
+ Assert.Equal(expectedStart, result);
+ }
+ }
+ else if (expression.EndsWith("/h"))
+ {
+ var hourStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day,
+ _baseTime.Hour, 0, 0, 0, _baseTime.Offset);
+
+ Assert.Equal(isUpperLimit ? hourStart.AddHours(1).AddMilliseconds(-1) : hourStart, result);
+ }
+ else if (expression.EndsWith("/m"))
+ {
+ var minuteStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day,
+ _baseTime.Hour, _baseTime.Minute, 0, 0, _baseTime.Offset);
+
+ Assert.Equal(isUpperLimit ? minuteStart.AddMinutes(1).AddMilliseconds(-1) : minuteStart, result);
+ }
+ }
+
+ [Theory]
+ [InlineData("now+1d+2h")]
+ [InlineData("now-1d+12h")]
+ [InlineData("now+1M+1d")]
+ [InlineData("now+1y-1M")]
+ public void Parse_MultipleOperations_ReturnsCorrectResult(string expression)
+ {
+ _logger.LogDebug("Testing Parse with multiple operations: '{Expression}', BaseTime: {BaseTime}",
+ expression, _baseTime);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // For simple combinations, we can verify exact results
+ if (expression == "now+1d+2h")
+ {
+ var expected = _baseTime.AddDays(1).AddHours(2);
+ Assert.Equal(expected, result);
+ }
+ else if (expression == "now-1d+12h")
+ {
+ var expected = _baseTime.AddDays(-1).AddHours(12);
+ Assert.Equal(expected, result);
+ }
+ else
+ {
+ // For month/year operations, just verify the result is reasonable
+ Assert.NotEqual(_baseTime, result);
+ }
+ }
+
+ [Theory]
+ [InlineData("2023-06-15||")]
+ [InlineData("2023-06-15T10:30:00||")]
+ [InlineData("2023-06-15T10:30:00.123||")]
+ public void Parse_ExplicitDateFormats_ReturnsCorrectResult(string expression)
+ {
+ _logger.LogDebug("Testing Parse with explicit date format: '{Expression}', BaseTime: {BaseTime}",
+ expression, _baseTime);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // All should resolve to June 15, 2023 in the base time's timezone
+ Assert.Equal(2023, result.Year);
+ Assert.Equal(6, result.Month);
+ Assert.Equal(15, result.Day);
+ Assert.Equal(_baseTime.Offset, result.Offset);
+ }
+
+ [Theory]
+ [InlineData("2023-06-15T10:30:00Z||", 0)]
+ [InlineData("2023-06-15T10:30:00+02:00||", 2)]
+ [InlineData("2023-06-15T10:30:00-05:00||", -5)]
+ [InlineData("2023-06-15T10:30:00+09:30||", 9.5)]
+ public void Parse_ExplicitTimezones_PreservesTimezone(string expression, double offsetHours)
+ {
+ _logger.LogDebug("Testing Parse with explicit timezone: '{Expression}', BaseTime: {BaseTime}, Expected offset hours: {OffsetHours}",
+ expression, _baseTime, offsetHours);
+
+ var result = DateMath.Parse(expression, _baseTime);
+ var expectedOffset = TimeSpan.FromHours(offsetHours);
+
+ _logger.LogDebug("Parse result: {Result}, Expected offset: {ExpectedOffset}", result, expectedOffset);
+
+ Assert.Equal(2023, result.Year);
+ Assert.Equal(6, result.Month);
+ Assert.Equal(15, result.Day);
+ Assert.Equal(10, result.Hour);
+ Assert.Equal(30, result.Minute);
+ Assert.Equal(expectedOffset, result.Offset);
+ }
+
+ [Theory]
+ [InlineData("2023-06-15||+1M")]
+ [InlineData("2023-06-15T10:30:00||+2d")]
+ [InlineData("2023-06-15T10:30:00Z||+1h")]
+ [InlineData("2023-06-15T10:30:00+02:00||-1d/d")]
+ public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string expression)
+ {
+ _logger.LogDebug("Testing Parse with explicit date and operations: '{Expression}', BaseTime: {BaseTime}",
+ expression, _baseTime);
+
+ var result = DateMath.Parse(expression, _baseTime);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // Verify it's not the base time and the operation was applied
+ Assert.NotEqual(_baseTime, result);
+
+ if (expression.Contains("+1M"))
+ {
+ Assert.Equal(7, result.Month); // June + 1 month = July
+ }
+ else if (expression.Contains("+2d"))
+ {
+ Assert.Equal(17, result.Day); // 15 + 2 days = 17
+ }
+ else if (expression.Contains("+1h"))
+ {
+ Assert.Equal(11, result.Hour); // 10 + 1 hour = 11
+ }
+ }
+
+ [Theory]
+ [InlineData("")]
+ [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
+ [InlineData("2023-01-32||")] // Invalid day
+ [InlineData("2001.02.01||")] // Dotted format no longer supported
+ [InlineData("now/d+1h")] // Rounding must be final operation
+ [InlineData("now/d/d")] // Multiple rounding operations
+ [InlineData("now+1h/d+2m")] // Rounding in middle of operations
+ public void Parse_InvalidExpressions_ThrowsArgumentException(string expression)
+ {
+ _logger.LogDebug("Testing Parse with invalid expression: '{Expression}', expecting ArgumentException", expression);
+
+ var exception = Assert.Throws(() => DateMath.Parse(expression, _baseTime));
+
+ _logger.LogDebug("Exception thrown as expected: {Message}", exception.Message);
+ Assert.Contains("Invalid date math expression", exception.Message);
+ }
+
+ [Fact]
+ public void Parse_NullExpression_ThrowsArgumentException()
+ {
+ _logger.LogDebug("Testing Parse with null expression, expecting ArgumentException");
+
+ var exception = Assert.Throws(() => DateMath.Parse(null!, _baseTime));
+
+ _logger.LogDebug("Exception thrown as expected: {Message}", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("now")]
+ [InlineData("now+1h")]
+ [InlineData("now-1d/d")]
+ [InlineData("2023-06-15||")]
+ [InlineData("2023-06-15||+1M/d")]
+ [InlineData("2025-01-01T01:25:35Z||+3d/d")]
+ public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string expression)
+ {
+ _logger.LogDebug("Testing TryParse with valid 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);
+ Assert.NotEqual(default, result);
+
+ // Verify TryParse and Parse return the same result
+ var parseResult = DateMath.Parse(expression, _baseTime, false);
+ Assert.Equal(parseResult, result);
+ }
+
+ [Theory]
+ [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
+ [InlineData("now/d/d")] // Multiple rounding operations
+ public void TryParse_InvalidExpressions_ReturnsFalse(string expression)
+ {
+ _logger.LogDebug("Testing TryParse with invalid expression: '{Expression}', expecting false", expression);
+
+ bool success = DateMath.TryParse(expression, _baseTime, false, out var result);
+
+ _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);
+
+ Assert.False(success);
+ Assert.Equal(default, result);
+ }
+
+ [Fact]
+ public void TryParse_NullExpression_ReturnsFalse()
+ {
+ _logger.LogDebug("Testing TryParse with null expression, expecting false");
+
+ bool success = DateMath.TryParse(null!, _baseTime, false, out var result);
+
+ _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);
+
+ Assert.False(success);
+ Assert.Equal(default, result);
+ }
+
+ [Theory]
+ [InlineData("now+1h", false)]
+ [InlineData("now-1d/d", true)]
+ [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)
+ {
+ _logger.LogDebug("Testing Parse vs TryParse consistency for expression: '{Expression}', IsUpperLimit: {IsUpperLimit}",
+ expression, isUpperLimit);
+
+ var parseResult = DateMath.Parse(expression, _baseTime, isUpperLimit);
+ bool tryParseSuccess = DateMath.TryParse(expression, _baseTime, isUpperLimit, out var tryParseResult);
+
+ _logger.LogDebug("Parse result: {ParseResult}, TryParse success: {TryParseSuccess}, TryParse result: {TryParseResult}",
+ parseResult, tryParseSuccess, tryParseResult);
+
+ Assert.True(tryParseSuccess);
+ Assert.Equal(parseResult, tryParseResult);
+ }
+
+ [Theory]
+ [InlineData("now/d")]
+ [InlineData("now/h")]
+ [InlineData("now/m")]
+ [InlineData("now+1d/d")]
+ [InlineData("now-1M/d")]
+ public void Parse_UpperLimitVsLowerLimit_ProducesDifferentResults(string expression)
+ {
+ _logger.LogDebug("Testing upper vs lower limit behavior for expression: '{Expression}'", expression);
+
+ var lowerResult = DateMath.Parse(expression, _baseTime, false);
+ var upperResult = DateMath.Parse(expression, _baseTime, true);
+
+ _logger.LogDebug("Lower limit result: {LowerResult}, Upper limit result: {UpperResult}",
+ lowerResult, upperResult);
+
+ // Upper limit should be later than lower limit for rounding operations
+ Assert.True(upperResult > lowerResult,
+ $"Upper limit ({upperResult}) should be greater than lower limit ({lowerResult})");
+ }
+
+ [Fact]
+ public void Parse_EdgeCase_LeapYear()
+ {
+ var leapYearDate = new DateTimeOffset(2024, 2, 28, 12, 0, 0, _baseTime.Offset);
+ const string expression = "now+1d";
+
+ _logger.LogDebug("Testing leap year edge case with date: {LeapYearDate}, expression: '{Expression}'",
+ leapYearDate, expression);
+
+ var result = DateMath.Parse(expression, leapYearDate);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ Assert.Equal(29, result.Day); // Should go to Feb 29 in leap year
+ Assert.Equal(2, result.Month);
+ }
+
+ [Fact]
+ public void Parse_EdgeCase_MonthOverflow()
+ {
+ var endOfMonth = new DateTimeOffset(2023, 1, 31, 12, 0, 0, _baseTime.Offset);
+ const string expression = "now+1M";
+
+ _logger.LogDebug("Testing month overflow edge case with date: {EndOfMonth}, expression: '{Expression}'",
+ endOfMonth, expression);
+
+ var result = DateMath.Parse(expression, endOfMonth);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // January 31 + 1 month should go to February 28 (or 29 in leap year)
+ Assert.Equal(2, result.Month);
+ Assert.True(result.Day <= 29);
+ }
+
+ [Fact]
+ public void Parse_EdgeCase_YearOverflow()
+ {
+ var endOfYear = new DateTimeOffset(2023, 12, 31, 23, 59, 59, _baseTime.Offset);
+ const string expression = "now+1d";
+
+ _logger.LogDebug("Testing year overflow edge case with date: {EndOfYear}, expression: '{Expression}'",
+ endOfYear, expression);
+
+ var result = DateMath.Parse(expression, endOfYear);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ Assert.Equal(2024, result.Year);
+ Assert.Equal(1, result.Month);
+ Assert.Equal(1, result.Day);
+ }
+
+ [Fact]
+ public void Parse_ComplexExpression_MultipleOperationsWithRounding()
+ {
+ const string expression = "now+1M-2d+3h/h";
+
+ _logger.LogDebug("Testing complex expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime);
+
+ var result = DateMath.Parse(expression, _baseTime, false);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // Should be rounded to start of hour
+ Assert.Equal(0, result.Minute);
+ Assert.Equal(0, result.Second);
+ Assert.Equal(0, result.Millisecond);
+
+ // Should not equal base time
+ Assert.NotEqual(_baseTime, result);
+ }
+
+ [Fact]
+ public void ParseTimeZone_Now_ReturnsCurrentTimeInSpecifiedTimezone()
+ {
+ var utcTimeZone = TimeZoneInfo.Utc;
+ const string expression = "now";
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, utcTimeZone.Id);
+
+ var result = DateMath.Parse(expression, utcTimeZone);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // Should be close to current UTC time
+ var utcNow = DateTimeOffset.UtcNow;
+ Assert.True(Math.Abs((result - utcNow).TotalSeconds) < 5,
+ $"Result {result} should be within 5 seconds of UTC now {utcNow}");
+ Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC
+ }
+
+ [Theory]
+ [InlineData("UTC", 0)]
+ [InlineData("US/Eastern", -5)] // EST offset (not considering DST for this test)
+ [InlineData("US/Pacific", -8)] // PST offset (not considering DST for this test)
+ public void ParseTimeZone_Now_ReturnsCorrectTimezone(string timeZoneId, int expectedOffsetHours)
+ {
+ var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
+ const string expression = "now";
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, timeZone.Id);
+
+ var result = DateMath.Parse(expression, timeZone);
+
+ _logger.LogDebug("Parse result: {Result}, Expected offset hours: {ExpectedOffsetHours}", result, expectedOffsetHours);
+
+ // Note: This test might need adjustment for DST, but it demonstrates the concept
+ Assert.Equal(timeZone.GetUtcOffset(DateTime.UtcNow), result.Offset);
+ }
+
+ [Fact]
+ public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone()
+ {
+ var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern");
+ const string expression = "2023-06-15T14:30:00||";
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, easternTimeZone.Id);
+
+ var result = DateMath.Parse(expression, easternTimeZone);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ Assert.Equal(2023, result.Year);
+ Assert.Equal(6, result.Month);
+ Assert.Equal(15, result.Day);
+ Assert.Equal(14, result.Hour);
+ Assert.Equal(30, result.Minute);
+ Assert.Equal(0, result.Second);
+
+ // Should use the timezone offset from Eastern Time
+ var expectedOffset = easternTimeZone.GetUtcOffset(new DateTime(2023, 6, 15, 14, 30, 0));
+ Assert.Equal(expectedOffset, result.Offset);
+ }
+
+ [Fact]
+ 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
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, pacificTimeZone.Id);
+
+ var result = DateMath.Parse(expression, pacificTimeZone);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ Assert.Equal(2023, result.Year);
+ Assert.Equal(6, result.Month);
+ Assert.Equal(15, result.Day);
+ Assert.Equal(14, result.Hour);
+ Assert.Equal(30, result.Minute);
+ Assert.Equal(0, result.Second);
+
+ // Should preserve the original +05:00 timezone, not use Pacific
+ Assert.Equal(TimeSpan.FromHours(5), result.Offset);
+ }
+
+ [Theory]
+ [InlineData("now+1h", 1)]
+ [InlineData("now+6h", 6)]
+ [InlineData("now-2h", -2)]
+ [InlineData("now+24h", 24)]
+ public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression, int hours)
+ {
+ var utcTimeZone = TimeZoneInfo.Utc;
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, Hours: {Hours}",
+ expression, utcTimeZone.Id, hours);
+
+ var result = DateMath.Parse(expression, utcTimeZone);
+ var utcNow = DateTimeOffset.UtcNow;
+ var expected = utcNow.AddHours(hours);
+
+ _logger.LogDebug("Parse result: {Result}, Expected: approximately {Expected}", result, expected);
+
+ // Should be close to expected time (within 5 seconds to account for execution time)
+ Assert.True(Math.Abs((result - expected).TotalSeconds) < 5,
+ $"Result {result} should be within 5 seconds of expected {expected}");
+ Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC
+ }
+
+ [Theory]
+ [InlineData("now/d", false)]
+ [InlineData("now/d", true)]
+ [InlineData("now/h", false)]
+ [InlineData("now/h", true)]
+ [InlineData("now/M", false)]
+ [InlineData("now/M", true)]
+ public void ParseTimeZone_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit)
+ {
+ var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central");
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, IsUpperLimit: {IsUpperLimit}",
+ expression, centralTimeZone.Id, isUpperLimit);
+
+ var result = DateMath.Parse(expression, centralTimeZone, isUpperLimit);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // Verify the result uses Central Time offset
+ var expectedOffset = centralTimeZone.GetUtcOffset(DateTime.UtcNow);
+ Assert.Equal(expectedOffset, result.Offset);
+
+ // Verify rounding behavior
+ if (expression.EndsWith("/d"))
+ {
+ if (isUpperLimit)
+ {
+ Assert.Equal(23, result.Hour);
+ Assert.Equal(59, result.Minute);
+ Assert.Equal(59, result.Second);
+ }
+ else
+ {
+ Assert.Equal(0, result.Hour);
+ Assert.Equal(0, result.Minute);
+ Assert.Equal(0, result.Second);
+ }
+ }
+ else if (expression.EndsWith("/h"))
+ {
+ if (isUpperLimit)
+ {
+ Assert.Equal(59, result.Minute);
+ Assert.Equal(59, result.Second);
+ }
+ else
+ {
+ Assert.Equal(0, result.Minute);
+ Assert.Equal(0, result.Second);
+ }
+ }
+ }
+
+ [Fact]
+ public void TryParseTimeZone_ValidExpression_ReturnsTrue()
+ {
+ var mountainTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Mountain");
+ const string expression = "now+2d";
+
+ _logger.LogDebug("Testing TryParse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, mountainTimeZone.Id);
+
+ bool success = DateMath.TryParse(expression, mountainTimeZone, false, out DateTimeOffset result);
+
+ _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);
+
+ Assert.True(success);
+ Assert.NotEqual(default(DateTimeOffset), result);
+
+ // Should use Mountain Time offset
+ var expectedOffset = mountainTimeZone.GetUtcOffset(DateTime.UtcNow);
+ Assert.Equal(expectedOffset, result.Offset);
+ }
+
+ [Fact]
+ public void TryParseTimeZone_InvalidExpression_ReturnsFalse()
+ {
+ var utcTimeZone = TimeZoneInfo.Utc;
+ const string expression = "invalid_expression";
+
+ _logger.LogDebug("Testing TryParse with TimeZoneInfo for invalid expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, utcTimeZone.Id);
+
+ bool success = DateMath.TryParse(expression, utcTimeZone, false, out DateTimeOffset result);
+
+ _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);
+
+ Assert.False(success);
+ Assert.Equal(default(DateTimeOffset), result);
+ }
+
+ [Fact]
+ public void ParseTimeZone_ComplexExpression_WorksCorrectly()
+ {
+ var utcTimeZone = TimeZoneInfo.Utc;
+ const string expression = "now+1M-2d+3h/h";
+
+ _logger.LogDebug("Testing Parse with TimeZoneInfo for complex expression: '{Expression}', TimeZone: {TimeZone}",
+ expression, utcTimeZone.Id);
+
+ var result = DateMath.Parse(expression, utcTimeZone, false);
+
+ _logger.LogDebug("Parse result: {Result}", result);
+
+ // Should be UTC
+ Assert.Equal(TimeSpan.Zero, result.Offset);
+
+ // Should be rounded to start of hour
+ Assert.Equal(0, result.Minute);
+ Assert.Equal(0, result.Second);
+ Assert.Equal(0, result.Millisecond);
+ }
+
+ [Fact]
+ public void ParseTimeZone_NullTimeZone_ThrowsArgumentNullException()
+ {
+ const string expression = "now";
+
+ _logger.LogDebug("Testing Parse with null TimeZoneInfo for expression: '{Expression}'", expression);
+
+ Assert.Throws(() => DateMath.Parse(expression, (TimeZoneInfo)null!));
+ }
+
+ [Fact]
+ public void TryParseTimeZone_NullTimeZone_ThrowsArgumentNullException()
+ {
+ const string expression = "now";
+
+ _logger.LogDebug("Testing TryParse with null TimeZoneInfo for expression: '{Expression}'", expression);
+
+ Assert.Throws(() => DateMath.TryParse(expression, (TimeZoneInfo)null!, false, out _));
+ }
+}
diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs
new file mode 100644
index 0000000..680d640
--- /dev/null
+++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs
@@ -0,0 +1,335 @@
+using System;
+using System.Collections.Generic;
+using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Exceptionless.DateTimeExtensions.Tests.FormatParsers.PartParsers;
+
+public class DateMathPartParserTests : PartParserTestsBase
+{
+ public DateMathPartParserTests(ITestOutputHelper output) : base(output) { }
+
+ [Theory]
+ [MemberData(nameof(NowInputs))]
+ public void ParseNowInput(string input, bool isUpperLimit, DateTimeOffset? expected)
+ {
+ ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected);
+ }
+
+ [Theory]
+ [MemberData(nameof(ExplicitDateInputs))]
+ public void ParseExplicitDateInput(string input, bool isUpperLimit, DateTimeOffset? expected)
+ {
+ ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected);
+ }
+
+ [Theory]
+ [MemberData(nameof(InvalidInputs))]
+ public void ParseInvalidInput(string input, bool isUpperLimit, DateTimeOffset? expected)
+ {
+ ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected);
+ }
+
+ [Theory]
+ [MemberData(nameof(ComplexOperationInputs))]
+ public void ParseComplexOperations(string input, bool isUpperLimit, DateTimeOffset? expected)
+ {
+ ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected);
+ }
+
+ [Theory]
+ [MemberData(nameof(RoundingInputs))]
+ public void ParseRoundingOperations(string input, bool isUpperLimit, DateTimeOffset? expected)
+ {
+ ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected);
+ }
+
+ [Theory]
+ [MemberData(nameof(EdgeCaseInputs))]
+ public void ParseEdgeCases(string input, bool isUpperLimit, DateTimeOffset? expected)
+ {
+ ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected);
+ }
+
+ public static IEnumerable