Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Exceptionless.DateTimeExtensions.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<Folder Name="/Solution Items/">
<File Path=".github/workflows/build.yml" />
<File Path="build/common.props" />
<File Path="global.json" />
<File Path="README.md" />
</Folder>
<Project Path="src/Exceptionless.DateTimeExtensions/Exceptionless.DateTimeExtensions.csproj" />
Expand Down
12 changes: 8 additions & 4 deletions src/Exceptionless.DateTimeExtensions/DateMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ public static class DateMath
// Match date math expressions with positional and end anchors for flexible matching
// Uses \G for positional matching and lookahead for boundary detection to support both
// full string parsing and positional matching within TwoPartFormatParser
// NOTE: Case-sensitive matching is intentional per Elasticsearch spec. The anchor 'now' must
// be lowercase, and date-math units are case-sensitive: y, M, w, d, h, H, m, s.
// Uppercase D is NOT a valid unit. See:
// https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options
internal static readonly Regex Parser = new(
@"\G(?<anchor>now|(?<date>\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" +
@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)(?=\s|$|[\]\}])",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
RegexOptions.Compiled);

// Pre-compiled regex for operation parsing to avoid repeated compilation
private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled);
Expand Down Expand Up @@ -141,7 +145,7 @@ public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTim
DateTimeOffset baseTime;
string anchor = match.Groups["anchor"].Value;

if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
if (String.Equals(anchor, "now"))
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This String.Equals call omits a StringComparison. Using String.Equals(anchor, "now", StringComparison.Ordinal) keeps the case-sensitive intent explicit and avoids potential analyzer warnings.

Suggested change
if (String.Equals(anchor, "now"))
if (String.Equals(anchor, "now", StringComparison.Ordinal))

Copilot uses AI. Check for mistakes.
{
baseTime = relativeBaseTime;
}
Expand Down Expand Up @@ -183,7 +187,7 @@ public static bool TryParseFromMatch(Match match, TimeZoneInfo timeZone, bool is
DateTimeOffset baseTime;
string anchor = match.Groups["anchor"].Value;

if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
if (String.Equals(anchor, "now"))
{
// Use current time in the specified timezone
baseTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone);
Expand Down Expand Up @@ -463,7 +467,7 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope
for (int i = 0; i < matches.Count; i++)
{
string operation = matches[i].Groups[1].Value;
if (operation == "/")
if (String.Equals(operation, "/"))
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This String.Equals call omits a StringComparison. Since this is a single-character token, consider either comparing operation[0] == '/' or using StringComparison.Ordinal to keep intent explicit and avoid analyzer warnings.

Suggested change
if (String.Equals(operation, "/"))
if (String.Equals(operation, "/", StringComparison.Ordinal))

Copilot uses AI. Check for mistakes.
{
if (foundRounding)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;

Expand All @@ -21,21 +21,26 @@ internal static class Helper

internal static TimeSpan GetTimeSpanFromName(string name)
{
return name.ToLower() switch
{
"minutes" => TimeSpan.FromMinutes(1),
"minute" => TimeSpan.FromMinutes(1),
"hours" => TimeSpan.FromHours(1),
"hour" => TimeSpan.FromHours(1),
"days" => TimeSpan.FromDays(1),
"day" => TimeSpan.FromDays(1),
_ => TimeSpan.Zero
};
if (String.Equals(name, "minutes", StringComparison.OrdinalIgnoreCase) ||
String.Equals(name, "minute", StringComparison.OrdinalIgnoreCase))
return TimeSpan.FromMinutes(1);

if (String.Equals(name, "hours", StringComparison.OrdinalIgnoreCase) ||
String.Equals(name, "hour", StringComparison.OrdinalIgnoreCase))
return TimeSpan.FromHours(1);

if (String.Equals(name, "days", StringComparison.OrdinalIgnoreCase) ||
String.Equals(name, "day", StringComparison.OrdinalIgnoreCase))
return TimeSpan.FromDays(1);

return TimeSpan.Zero;
}

internal static int GetMonthNumber(string name)
{
int index = MonthNames.FindIndex(m => m.Equals(name, StringComparison.OrdinalIgnoreCase) || m.Substring(0, 3).Equals(name, StringComparison.OrdinalIgnoreCase));
int index = MonthNames.FindIndex(m =>
String.Equals(m, name, StringComparison.OrdinalIgnoreCase) ||
String.Equals(m.Substring(0, 3), name, StringComparison.OrdinalIgnoreCase));
return index >= 0 ? index + 1 : -1;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public virtual DateTimeRange Parse(string content, DateTimeOffset relativeBaseTi
if (!m.Success)
return null;

string relation = m.Groups["relation"].Value.ToLower();
string relation = m.Groups["relation"].Value.ToLowerInvariant();
int month = Helper.GetMonthNumber(m.Groups["month"].Value);
return FromMonthRelation(relation, month, relativeBaseTime);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
if (!m.Success)
return null;

string value = m.Groups["name"].Value.ToLower();
if (value == "today")
string value = m.Groups["name"].Value;
if (String.Equals(value, "today", StringComparison.OrdinalIgnoreCase))
return new DateTimeRange(relativeBaseTime.StartOfDay(), relativeBaseTime.EndOfDay());
if (value == "yesterday")
if (String.Equals(value, "yesterday", StringComparison.OrdinalIgnoreCase))
return new DateTimeRange(relativeBaseTime.SubtractDays(1).StartOfDay(), relativeBaseTime.SubtractDays(1).EndOfDay());
if (value == "tomorrow")
if (String.Equals(value, "tomorrow", StringComparison.OrdinalIgnoreCase))
return new DateTimeRange(relativeBaseTime.AddDays(1).StartOfDay(), relativeBaseTime.AddDays(1).EndOfDay());

return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Text.RegularExpressions;

namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
Expand All @@ -21,8 +21,7 @@ public class AmountTimeRelationPartParser : IPartParser

protected DateTimeOffset? FromRelationAmountTime(string relation, int amount, string size, DateTimeOffset relativeBaseTime, bool isUpperLimit)
{
relation = relation.ToLower();
size = size.ToLower();
relation = relation.ToLowerInvariant();
if (amount < 1)
throw new ArgumentException("Time amount can't be 0.");
var intervalSpan = Helper.GetTimeSpanFromName(size);
Expand All @@ -38,7 +37,7 @@ public class AmountTimeRelationPartParser : IPartParser
return isUpperLimit ? relativeBaseTime.SafeAdd(totalSpan).Ceiling(intervalSpan).SubtractMilliseconds(1) : relativeBaseTime.SafeAdd(totalSpan).Floor(intervalSpan);
}
}
else if (size == "week" || size == "weeks")
else if (String.Equals(size, "week", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "weeks", StringComparison.OrdinalIgnoreCase))
{
switch (relation)
{
Expand All @@ -48,7 +47,7 @@ public class AmountTimeRelationPartParser : IPartParser
return isUpperLimit ? relativeBaseTime.AddWeeks(amount).EndOfDay() : relativeBaseTime.AddWeeks(amount).StartOfDay();
}
}
else if (size == "month" || size == "months")
else if (String.Equals(size, "month", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "months", StringComparison.OrdinalIgnoreCase))
{
switch (relation)
{
Expand All @@ -58,7 +57,7 @@ public class AmountTimeRelationPartParser : IPartParser
return isUpperLimit ? relativeBaseTime.AddMonths(amount).EndOfDay() : relativeBaseTime.AddMonths(amount).StartOfDay();
}
}
else if (size == "year" || size == "years")
else if (String.Equals(size, "year", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "years", StringComparison.OrdinalIgnoreCase))
{
switch (relation)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class MonthRelationPartParser : IPartParser

public virtual DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
{
string relation = match.Groups["relation"].Value.ToLower();
string relation = match.Groups["relation"].Value.ToLowerInvariant();
int month = Helper.GetMonthNumber(match.Groups["month"].Value);
return FromMonthRelation(relation, month, relativeBaseTime, isUpperLimit);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Text.RegularExpressions;

namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
Expand All @@ -12,14 +12,14 @@ public class NamedDayPartParser : IPartParser

public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
{
string value = match.Groups["name"].Value.ToLower();
if (value == "now")
string value = match.Groups["name"].Value;
if (String.Equals(value, "now", StringComparison.OrdinalIgnoreCase))
return relativeBaseTime;
if (value == "today")
if (String.Equals(value, "today", StringComparison.OrdinalIgnoreCase))
return isUpperLimit ? relativeBaseTime.EndOfDay() : relativeBaseTime.StartOfDay();
if (value == "yesterday")
if (String.Equals(value, "yesterday", StringComparison.OrdinalIgnoreCase))
return isUpperLimit ? relativeBaseTime.SubtractDays(1).EndOfDay() : relativeBaseTime.SubtractDays(1).StartOfDay();
if (value == "tomorrow")
if (String.Equals(value, "tomorrow", StringComparison.OrdinalIgnoreCase))
return isUpperLimit ? relativeBaseTime.AddDays(1).EndOfDay() : relativeBaseTime.AddDays(1).StartOfDay();

return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ public virtual DateTimeRange Parse(string content, DateTimeOffset relativeBaseTi

protected DateTimeRange FromRelationAmountTime(string relation, int amount, string size, DateTimeOffset relativeBaseTime)
{
relation = relation.ToLower();
size = size.ToLower();
relation = relation.ToLowerInvariant();
if (amount < 1)
throw new ArgumentException("Time amount can't be 0.");

Expand All @@ -40,7 +39,7 @@ protected DateTimeRange FromRelationAmountTime(string relation, int amount, stri
return new DateTimeRange(relativeBaseTime, relativeBaseTime.SafeAdd(totalSpan).Ceiling(intervalSpan).SubtractMilliseconds(1));
}
}
else if (size == "week" || size == "weeks")
else if (String.Equals(size, "week", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "weeks", StringComparison.OrdinalIgnoreCase))
{
switch (relation)
{
Expand All @@ -53,7 +52,7 @@ protected DateTimeRange FromRelationAmountTime(string relation, int amount, stri
return new DateTimeRange(relativeBaseTime, relativeBaseTime.AddWeeks(amount).EndOfDay());
}
}
else if (size == "month" || size == "months")
else if (String.Equals(size, "month", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "months", StringComparison.OrdinalIgnoreCase))
{
switch (relation)
{
Expand All @@ -66,7 +65,7 @@ protected DateTimeRange FromRelationAmountTime(string relation, int amount, stri
return new DateTimeRange(relativeBaseTime, relativeBaseTime.AddMonths(amount).EndOfDay());
}
}
else if (size == "year" || size == "years")
else if (String.Equals(size, "year", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "years", StringComparison.OrdinalIgnoreCase))
{
switch (relation)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ private static bool IsValidBracketPair(string opening, string closing)
return false;

// Check for proper matching pairs
return (opening == "[" && closing == "]") ||
(opening == "{" && closing == "}");
return (String.Equals(opening, "[") && String.Equals(closing, "]")) ||
(String.Equals(opening, "{") && String.Equals(closing, "}"));
Comment on lines +105 to +106
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These String.Equals calls omit StringComparison. To keep comparisons explicit and avoid analyzer warnings, use StringComparison.Ordinal for these bracket literals (preserves previous behavior and remains null-safe).

Suggested change
return (String.Equals(opening, "[") && String.Equals(closing, "]")) ||
(String.Equals(opening, "{") && String.Equals(closing, "}"));
return (String.Equals(opening, "[", StringComparison.Ordinal) && String.Equals(closing, "]", StringComparison.Ordinal)) ||
(String.Equals(opening, "{", StringComparison.Ordinal) && String.Equals(closing, "}", StringComparison.Ordinal));

Copilot uses AI. Check for mistakes.
}
}
4 changes: 2 additions & 2 deletions src/Exceptionless.DateTimeExtensions/TimeSpanExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Text;

namespace Exceptionless.DateTimeExtensions;
Expand Down Expand Up @@ -205,7 +205,7 @@ private static bool AppendPart(StringBuilder builder, string partName, double pa

string partValueString = partCount > 0 ? Math.Abs(partValue).ToString("0.##") : partValue.ToString("0.##");

if (shortForm && partName == "millisecond")
if (shortForm && String.Equals(partName, "millisecond"))
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using String.Equals without a StringComparison can trigger analyzers (e.g., CA1307) and makes the intended comparison semantics less explicit. Consider using String.Equals(partName, "millisecond", StringComparison.Ordinal) here (still null-safe and equivalent to the previous ==).

Suggested change
if (shortForm && String.Equals(partName, "millisecond"))
if (shortForm && String.Equals(partName, "millisecond", StringComparison.Ordinal))

Copilot uses AI. Check for mistakes.
partName = "ms";
else if (shortForm)
partName = partName.Substring(0, 1);
Expand Down
92 changes: 91 additions & 1 deletion tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public void Parse_Now_ReturnsBaseTime(string expression, bool isUpperLimit)
[InlineData("now+1h", 1)]
[InlineData("now+2h", 2)]
[InlineData("now+24h", 24)]
[InlineData("now+1H", 1)] // Case insensitive
[InlineData("now+1H", 1)] // Both h and H are valid Elastic units for hours
[InlineData("now-1h", -1)]
[InlineData("now-12h", -12)]
public void Parse_HourOperations_ReturnsCorrectResult(string expression, int hours)
Expand Down Expand Up @@ -318,6 +318,10 @@ public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string express
[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
[InlineData("Now")] // 'now' must be lowercase per Elastic spec
[InlineData("NOW")]
[InlineData("NOW+1h")]
[InlineData("Now-1d/d")]
public void Parse_InvalidExpressions_ThrowsArgumentException(string expression)
{
_logger.LogDebug("Testing Parse with invalid expression: '{Expression}', expecting ArgumentException", expression);
Expand Down Expand Up @@ -371,6 +375,8 @@ public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string express
[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")] // 'now' must be lowercase
[InlineData("NOW-1d")]
public void TryParse_InvalidExpressions_ReturnsFalse(string expression)
{
_logger.LogDebug("Testing TryParse with invalid expression: '{Expression}', expecting false", expression);
Expand Down Expand Up @@ -815,4 +821,88 @@ public void TryParseTimeZone_NullTimeZone_ThrowsArgumentNullException()

Assert.Throws<ArgumentNullException>(() => DateMath.TryParse(expression, (TimeZoneInfo)null!, false, out _));
}

/// <summary>
/// Per Elasticsearch docs, valid date-math units are case-sensitive:
/// y, M, w, d, h, H, m, s. Uppercase D, Y, W, S are NOT valid units.
/// https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options
/// </summary>
[Theory]
[InlineData("now-7D")]
[InlineData("now-1D")]
[InlineData("now-30D")]
[InlineData("now+1D")]
[InlineData("now-1Y")]
[InlineData("now-1W")]
[InlineData("now-1S")]
[InlineData("now/D")]
public void Parse_UppercaseInvalidUnits_ThrowsArgumentException(string expression)
{
// Arrange
_logger.LogDebug("Testing Parse with invalid uppercase unit: '{Expression}', expecting ArgumentException", expression);

// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => DateMath.Parse(expression, _baseTime));

_logger.LogDebug("Exception thrown as expected: {Message}", exception.Message);
}

[Theory]
[InlineData("now-7D")]
[InlineData("now-1D")]
[InlineData("now+1D")]
public void TryParse_UppercaseInvalidUnits_ReturnsFalse(string expression)
{
// Arrange
_logger.LogDebug("Testing TryParse with invalid uppercase unit: '{Expression}', expecting false", expression);

// Act
bool success = DateMath.TryParse(expression, _baseTime, false, out var result);

_logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result);

// Assert
Assert.False(success);
Assert.Equal(default, result);
}

[Fact]
public void Parse_UppercaseAndLowercaseM_ProduceDifferentResults()
{
// Arrange
var minuteExpression = "now-1m";
var monthExpression = "now+1M";

_logger.LogDebug("Testing case-sensitive distinction: 'm' (minutes) vs 'M' (months), BaseTime: {BaseTime}",
_baseTime);

// Act
var minuteResult = DateMath.Parse(minuteExpression, _baseTime);
var monthResult = DateMath.Parse(monthExpression, _baseTime);

_logger.LogDebug("now-1m result: {MinuteResult}, now+1M result: {MonthResult}", minuteResult, monthResult);

// Assert
Assert.Equal(_baseTime.AddMinutes(-1), minuteResult);
Assert.Equal(_baseTime.AddMonths(1), monthResult);
}

[Fact]
public void IsValidExpression_CaseSensitiveInputs_ValidatesCorrectly()
{
// Arrange
_logger.LogDebug("Testing IsValidExpression with valid and invalid case-sensitive expressions");

// Act & Assert - valid expressions
Assert.True(DateMath.IsValidExpression("now-7d"));
Assert.True(DateMath.IsValidExpression("now-1d/d"));

// Act & Assert - uppercase D is not a valid unit
Assert.False(DateMath.IsValidExpression("now-7D"));
Assert.False(DateMath.IsValidExpression("now-1D/D"));

// Act & Assert - 'now' must be lowercase
Assert.False(DateMath.IsValidExpression("Now-7d"));
Assert.False(DateMath.IsValidExpression("NOW-7d"));
}
}
Loading
Loading