Skip to content

Commit ca74b5c

Browse files
committed
Support negative duration inputs (#15)
Enhanced the `DurationTimeParser` to include support for negative durations in both standard and human-readable formats. By doing so the RegEx strings were extended and a bit reorganized to make their purpose clear and easy understandable. In addition, because of rounding issues, it was necessary to split up the rounding logic into separate steps. The issues cause is still not clear, but with the one-liner we ended up with nano-seconds in the result. All unit tests were extensively extended to cover negative cases, edge cases, boundary values, whitespace-padded input, case-insensitive input, and fractional unit handling - all related to negative inputs. Furthermore existing tests were restructured for an easier readability. Item: #14
1 parent 260c7a7 commit ca74b5c

10 files changed

Lines changed: 1351 additions & 139 deletions

benchmarks/DurationMancer.Benchmarks/Configuration.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using BenchmarkDotNet.Columns;
22
using BenchmarkDotNet.Configs;
33
using BenchmarkDotNet.Environments;
4-
using BenchmarkDotNet.Exporters;
54
using BenchmarkDotNet.Jobs;
65

76
namespace DurationMancer.Benchmarks;

benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.Parse.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,28 @@ public class Parse
1515
public TimeSpan Standard_HHmmss()
1616
=> DurationTimeParser.Parse("12:30:45");
1717

18+
[Benchmark(Description = "Standard negative: -HH:mm:ss")]
19+
public TimeSpan Standard_NegativeHHmmss()
20+
=> DurationTimeParser.Parse("-12:30:45");
21+
1822
[Benchmark(Description = "Standard: d.HH:mm:ss.fff")]
1923
public TimeSpan Standard_Full()
2024
=> DurationTimeParser.Parse("3.08:15:30.250");
2125

26+
[Benchmark(Description = "Standard negative: -d.HH:mm:ss.fff")]
27+
public TimeSpan Standard_NegativeFull()
28+
=> DurationTimeParser.Parse("-3.08:15:30.250");
29+
2230
// --- Human-readable format inputs ---
2331

2432
[Benchmark(Description = "Human: single unit (5m)")]
2533
public TimeSpan Human_SingleUnit()
2634
=> DurationTimeParser.Parse("5m");
2735

36+
[Benchmark(Description = "Human: single unit negative (-5m)")]
37+
public TimeSpan Human_NegativeSingleUnit()
38+
=> DurationTimeParser.Parse("-5m");
39+
2840
[Benchmark(Description = "Human: two units (1h 30m)")]
2941
public TimeSpan Human_TwoUnits()
3042
=> DurationTimeParser.Parse("1h 30m");
@@ -33,6 +45,10 @@ public TimeSpan Human_TwoUnits()
3345
public TimeSpan Human_FullCombo()
3446
=> DurationTimeParser.Parse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds");
3547

48+
[Benchmark(Description = "Human: full combo negative")]
49+
public TimeSpan Human_NegativeFullCombo()
50+
=> DurationTimeParser.Parse("-2 days 4 hours 15 minutes 30 seconds 500 milliseconds");
51+
3652
[Benchmark(Description = "Human: decimals (1.5h 100ms)")]
3753
public TimeSpan Human_Decimals()
3854
=> DurationTimeParser.Parse("1.5h 100ms");

benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.TryParse.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,24 @@ public bool Standard_HHmmss()
1717
return DurationTimeParser.TryParse("12:30:45", out _);
1818
}
1919

20+
[Benchmark(Description = "Standard negative: -HH:mm:ss")]
21+
public bool Standard_NegativeHHmmss()
22+
{
23+
return DurationTimeParser.TryParse("-12:30:45", out _);
24+
}
25+
2026
[Benchmark(Description = "Standard: d.HH:mm:ss.fff")]
2127
public bool Standard_Full()
2228
{
2329
return DurationTimeParser.TryParse("3.08:15:30.250", out _);
2430
}
2531

32+
[Benchmark(Description = "Standard negative: -d.HH:mm:ss.fff")]
33+
public bool Standard_NegativeFull()
34+
{
35+
return DurationTimeParser.TryParse("-3.08:15:30.250", out _);
36+
}
37+
2638
// --- Human-readable format inputs ---
2739

2840
[Benchmark(Description = "Human: single unit (5m)")]
@@ -31,6 +43,12 @@ public bool Human_SingleUnit()
3143
return DurationTimeParser.TryParse("5m", out _);
3244
}
3345

46+
[Benchmark(Description = "Human: single unit negative (-5m)")]
47+
public bool Human_NegativeSingleUnit()
48+
{
49+
return DurationTimeParser.TryParse("-5m", out _);
50+
}
51+
3452
[Benchmark(Description = "Human: two units (1h 30m)")]
3553
public bool Human_TwoUnits()
3654
{
@@ -43,6 +61,12 @@ public bool Human_FullCombo()
4361
return DurationTimeParser.TryParse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _);
4462
}
4563

64+
[Benchmark(Description = "Human: full combo negative")]
65+
public bool Human_NegativeFullCombo()
66+
{
67+
return DurationTimeParser.TryParse("-2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _);
68+
}
69+
4670
[Benchmark(Description = "Human: decimals (1.5h 100ms)")]
4771
public bool Human_Decimals()
4872
{

src/DurationMancer/DurationTimeParser.cs

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,28 @@ namespace DurationMancer;
88
/// </summary>
99
public static partial class DurationTimeParser
1010
{
11+
private const string MinusPattern = @"(?<minus>-)?\s*";
1112
private const string DaysPattern = @"(?<days>\d+(?:\.\d+)?)\s*(d|day|days)\b";
1213
private const string HoursPattern = @"(?<hours>\d+(?:\.\d+)?)\s*(h|hour|hours)\b";
1314
private const string MillisecondsPattern = @"(?<milliseconds>\d+)\s*(ms|millisecond|milliseconds)\b";
1415
private const string MinutesPattern = @"(?<minutes>\d+(?:\.\d+)?)\s*(m|min|minute|minutes)\b";
1516
private const string SecondsPattern = @"(?<seconds>\d+(?:\.\d+)?)\s*(s|sec|second|seconds)\b";
1617

1718
private const string StandardDurationTimeFormat =
18-
@"^(?:(?<d>\d+)\.)?(?<hh>[01]\d|2[0-3]):(?<mm>[0-5]\d):(?<ss>[0-5]\d)(?:\.(?<ms>\d{0,3}))?$";
19+
@$"^{MinusPattern}(?:(?<d>\d+)\.)?(?<hh>[01]\d|2[0-3]):(?<mm>[0-5]\d):(?<ss>[0-5]\d)(?:\.(?<ms>\d{{0,3}}))?$";
1920

20-
private const string TimePattern = $"{StandardDurationTimeFormat}|" +
21-
@$"^(?:{DaysPattern})?\s*" +
22-
@$"(?:{HoursPattern})?\s*" +
23-
@$"(?:{MinutesPattern})?\s*" +
24-
@$"(?:{SecondsPattern})?\s*" +
25-
$"(?:{MillisecondsPattern})?$";
21+
private const string AnyHumanReadableUnitPattern =
22+
@"\d+(?:\.\d+)?\s*(?:d|day|days|h|hour|hours|m|min|minute|minutes|s|sec|second|seconds)\b|\d+\s*(?:ms|millisecond|milliseconds)\b";
23+
24+
private const string HumanReadableDurationTimeFormat =
25+
@$"^{MinusPattern}(?=.*(?:{AnyHumanReadableUnitPattern}))" +
26+
@$"(?:{DaysPattern})?\s*" +
27+
@$"(?:{HoursPattern})?\s*" +
28+
@$"(?:{MinutesPattern})?\s*" +
29+
@$"(?:{SecondsPattern})?\s*" +
30+
@$"(?:{MillisecondsPattern})?$";
31+
32+
private const string TimePattern = $"{StandardDurationTimeFormat}|{HumanReadableDurationTimeFormat}";
2633

2734
private static readonly Regex TimeRegex = CreateTimeFormatRegex();
2835

@@ -40,8 +47,8 @@ public static partial class DurationTimeParser
4047
/// Supports two format types:
4148
/// <para>
4249
/// 1. Standard duration format:<br />
43-
/// - [d.]HH:mm:ss[.fff]<br />
44-
/// 2. Human-readable format with combinations of:<br />
50+
/// - [-][d.]HH:mm:ss[.fff]<br />
51+
/// 2. Human-readable format with combinations of the following elements, optionally preceded by "-":<br />
4552
/// - days: "1d", "2 days"<br />
4653
/// - hours: "1h", "2 hours"<br />
4754
/// - minutes: "1m", "5 minutes"<br />
@@ -51,6 +58,7 @@ public static partial class DurationTimeParser
5158
/// Examples of valid human-readable formats:<br />
5259
/// - "1s"<br />
5360
/// - "5 minutes"<br />
61+
/// - "-7 minutes"<br />
5462
/// - "3m 10s"<br />
5563
/// - "1 hour 30 minutes"<br />
5664
/// - "2 days 4 hours 15m 30s"<br />
@@ -73,12 +81,24 @@ public static bool TryParse(string? input, out TimeSpan timeSpan)
7381
return false;
7482
}
7583

76-
return TryParseDurationFormat(match, out timeSpan) ||
77-
TryParseHumanReadableDurationFormat(match, out timeSpan);
84+
if (!TryParseDurationFormat(match, out timeSpan) &&
85+
!TryParseHumanReadableDurationFormat(match, out timeSpan))
86+
{
87+
return false;
88+
}
89+
90+
var isNegative = match.Groups["minus"].Success;
91+
if (isNegative)
92+
{
93+
timeSpan = -timeSpan;
94+
}
95+
96+
return true;
7897
}
7998

8099
/// <summary>
81100
/// Parses a given input string into a <see cref="TimeSpan" /> representation.
101+
/// Supports standard duration format and human-readable duration format.
82102
/// Throws a <see cref="FormatException" /> if the input string is not in a valid duration format.
83103
/// </summary>
84104
/// <param name="input">The input string that represents a duration in a valid format.</param>
@@ -91,7 +111,8 @@ public static TimeSpan Parse(string? input)
91111

92112
return TryParse(input, out var result)
93113
? result
94-
: throw new FormatException($"Input string '{input}' was not in a correct duration format.");
114+
: throw new FormatException(
115+
$"The input string '{input}' was not recognized as a valid duration. Expected either standard format '[-][d.]HH:mm:ss[.fff]' or human-readable format (e.g. '[-]1d 2h 30m 15s 100ms').");
95116
}
96117

97118
/// <summary>
@@ -193,8 +214,12 @@ private static bool TryParseHumanReadableDurationFormat(Match match, out TimeSpa
193214
/// <returns>The new rounded <see cref="TimeSpan" /> instance.</returns>
194215
private static TimeSpan RoundOnMilliseconds(TimeSpan timeSpan)
195216
{
196-
var roundedTicks =
197-
(long)(Math.Round((double)timeSpan.Ticks / TimeSpan.TicksPerMillisecond) * TimeSpan.TicksPerMillisecond);
217+
var milliseconds = (double)timeSpan.Ticks / TimeSpan.TicksPerMillisecond;
218+
219+
// Round to the nearest millisecond
220+
var roundedMilliseconds = Math.Round(milliseconds);
221+
222+
var roundedTicks = (long)roundedMilliseconds * TimeSpan.TicksPerMillisecond;
198223

199224
return new TimeSpan(roundedTicks);
200225
}

0 commit comments

Comments
 (0)