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
63 changes: 63 additions & 0 deletions .github/workflows/benchmarks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Benchmarks

on:
workflow_dispatch:

permissions:
contents: read

env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_NOLOGO: 1
benchmarksPath: './benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj'
buildConfiguration: 'Release'

jobs:
benchmarks:
name: Run Benchmarks
runs-on: ubuntu-latest
steps:
- name: Code checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis and for GitVersion based versioning

# --- GitVersion ---
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v4.2.0
with:
versionSpec: '6.5.x'
- name: Determine Version
id: version_step # step id used as reference for output values
uses: gittools/actions/gitversion/command@v4.2.0
with:
arguments: '/output buildserver /l console /config GitVersion.yaml /updateprojectfiles' # have to self-define all arguments; /updateprojectfiles not impl. in execute task

# --- Setup .NET ---
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

# --- Restore ---
- name: Restore
run: dotnet restore ${{ env.benchmarksPath }}

# --- Build ---
- name: Build ${{ env.GitVersion_FullSemVer }}
run: dotnet build ${{ env.benchmarksPath }} --no-restore -c Release

# --- Run Benchmarks ---
- name: Run Benchmarks for ${{ env.GitVersion_FullSemVer }}
run: dotnet run --project ${{ env.benchmarksPath }} --no-build -c Release -f net10.0

# --- Publish Benchmark Results ---
- name: Upload Benchmark Results
uses: actions/upload-artifact@v4
with:
name: BenchmarkResults-${{ env.GitVersion_FullSemVer }}
path: ./BenchmarkDotNet.Artifacts/results
retention-days: 30
26 changes: 18 additions & 8 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ permissions:
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1 # Opt out of telemetry to speed up the build
DOTNET_NOLOGO: 1 # Prevents the dotnet CLI logo from displaying that speeds up build times
solutionPath: './DurationMancer.slnx'
libraryPath: './src/DurationMancer/DurationMancer.csproj'
testsPath: './tests/DurationMancer.Tests/DurationMancer.Tests.csproj'
buildConfiguration: 'Release'
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

Expand Down Expand Up @@ -56,8 +57,10 @@ jobs:
run: dotnet tool install dotnet-sonarscanner --global

# --- Restore ---
- name: Restore dependencies
run: dotnet restore ${{ env.solutionPath }}
- name: Restore library
run: dotnet restore ${{ env.libraryPath }}
- name: Restore tests
run: dotnet restore ${{ env.testsPath }}

# --- SonarCloud begin ---
- name: Prepare SonarQube analyze
Expand All @@ -67,18 +70,25 @@ jobs:
/v:"${{ env.GitVersion_FullSemVer }}"
/d:sonar.token="$SONAR_TOKEN"
/d:sonar.cs.opencover.reportsPaths=./TestResults/coverage*.opencover.xml
/d:sonar.exclusions="benchmarks/**"
/d:sonar.coverage.exclusions="benchmarks/**"

# --- Build ---
- name: Build ${{ env.GitVersion_FullSemVer }}
- name: Build library ${{ env.GitVersion_FullSemVer }}
run: >
dotnet build ${{ env.solutionPath }}
dotnet build ${{ env.libraryPath }}
--no-restore
-c ${{ env.buildConfiguration }}
- name: Build tests
run: >
dotnet build ${{ env.testsPath }}
--no-restore
-c ${{ env.buildConfiguration }}

# --- Test ---
- name: Test ${{ env.solutionPath }}
- name: Test
run: >
dotnet test ${{ env.solutionPath }} --no-build -c ${{ env.buildConfiguration }}
dotnet test ${{ env.testsPath }} --no-build -c ${{ env.buildConfiguration }}
--logger trx
--results-directory "./TestResults/"
--logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"
Expand All @@ -101,7 +111,7 @@ jobs:
# --- NuGet Pack + Upload ---
- name: Pack NuGet package
run: >
dotnet pack ${{ env.solutionPath }}
dotnet pack ${{ env.libraryPath }}
--no-build
-c ${{ env.buildConfiguration }}
-o ./nupkg
Expand Down
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
<!-- tests/DurationMancer.Tests -->
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
<PackageVersion Include="AwesomeAssertions.Analyzers" Version="9.0.8" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
</Project>
1 change: 1 addition & 0 deletions DurationMancer.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<Solution>
<Project Path="benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj" />
<Project Path="src/DurationMancer/DurationMancer.csproj" />
<Project Path="tests/DurationMancer.Tests/DurationMancer.Tests.csproj" />
</Solution>
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ This format is best for precise, structured input. It follows the pattern: `[day
- `1.05:00:00` → 1 day, 5 hours
- `00:00:45.500` → 45.5 seconds

---

### Human-Readable Format

This format is ideal for quick CLI input. You can mix and match units using full names or shorthand.
Expand Down Expand Up @@ -83,3 +81,46 @@ This format is ideal for quick CLI input. You can mix and match units using full
- `1.5 hours`
- `100ms`
- `2days 4h 15m 30s`

## Benchmarks

```
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2)
AMD Ryzen 9 7950X3D 4.20GHz, 1 CPU, 32 logical and 16 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4
.NET 10 : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4
.NET 9 : .NET 9.0.13 (9.0.13, 9.0.1326.6317), X64 RyuJIT x86-64-v4
.NET 8 : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4
```

| Method | Runtime | Mean | Error | StdDev | P95 | Ratio | RatioSD | Rank | Gen0 | Allocated |
| ------------------------------ | --------- | ----------: | ---------: | --------: | ----------: | ----: | ------: | ---: | -----: | --------: |
| 'Standard: HH:mm:ss' | .NET 10.0 | 298.1505 ns | 2.4080 ns | 2.2524 ns | 301.3607 ns | 8.02 | 0.06 | 3 | 0.0348 | 1752 B |
| 'Standard: d.HH:mm:ss.fff' | .NET 10.0 | 337.5800 ns | 6.5587 ns | 6.1350 ns | 346.8909 ns | 9.08 | 0.16 | 4 | 0.0362 | 1840 B |
| 'Human: single unit (5m)' | .NET 10.0 | 392.8773 ns | 3.5938 ns | 3.3616 ns | 397.7572 ns | 10.56 | 0.09 | 5 | 0.0339 | 1712 B |
| 'Human: two units (1h 30m)' | .NET 10.0 | 452.5045 ns | 2.3685 ns | 1.9778 ns | 454.8526 ns | 12.17 | 0.06 | 6 | 0.0353 | 1776 B |
| 'Human: full combo' \* | .NET 10.0 | 759.7605 ns | 6.1496 ns | 5.7524 ns | 768.1694 ns | 20.43 | 0.16 | 8 | 0.0401 | 2024 B |
| 'Human: decimals (1.5h 100ms)' | .NET 10.0 | 560.4385 ns | 2.5589 ns | 2.2684 ns | 563.9663 ns | 15.07 | 0.07 | 7 | 0.0372 | 1880 B |
| 'Invalid input' | .NET 10.0 | 37.1920 ns | 0.0890 ns | 0.0789 ns | 37.3029 ns | 1.00 | 0.00 | 2 | - | - |
| 'Null input' | .NET 10.0 | 0.8341 ns | 0.0046 ns | 0.0038 ns | 0.8401 ns | 0.02 | 0.00 | 1 | - | - |
| | | | | | | | | | | |
| 'Standard: HH:mm:ss' | .NET 9.0 | 354.0586 ns | 7.0855 ns | 8.1597 ns | 363.1163 ns | 6.11 | 0.14 | 3 | 0.0348 | 1752 B |
| 'Standard: d.HH:mm:ss.fff' | .NET 9.0 | 397.6152 ns | 6.9474 ns | 6.4986 ns | 406.1554 ns | 6.86 | 0.12 | 4 | 0.0362 | 1840 B |
| 'Human: single unit (5m)' | .NET 9.0 | 471.8717 ns | 9.2507 ns | 9.4998 ns | 485.1973 ns | 8.15 | 0.17 | 5 | 0.0339 | 1712 B |
| 'Human: two units (1h 30m)' | .NET 9.0 | 536.7581 ns | 10.7092 ns | 8.9427 ns | 546.0439 ns | 9.27 | 0.16 | 6 | 0.0353 | 1776 B |
| 'Human: full combo' \* | .NET 9.0 | 873.6216 ns | 8.8310 ns | 7.8284 ns | 886.2697 ns | 15.08 | 0.17 | 8 | 0.0401 | 2024 B |
| 'Human: decimals (1.5h 100ms)' | .NET 9.0 | 690.4374 ns | 4.3499 ns | 3.8561 ns | 696.4002 ns | 11.92 | 0.10 | 7 | 0.0372 | 1880 B |
| 'Invalid input' | .NET 9.0 | 57.9295 ns | 0.2371 ns | 0.4027 ns | 58.8710 ns | 1.00 | 0.01 | 2 | - | - |
| 'Null input' | .NET 9.0 | 0.8429 ns | 0.0099 ns | 0.0087 ns | 0.8568 ns | 0.01 | 0.00 | 1 | - | - |
| | | | | | | | | | | |
| 'Standard: HH:mm:ss' | .NET 8.0 | 315.1999 ns | 2.1455 ns | 1.9019 ns | 318.1252 ns | 5.29 | 0.04 | 3 | 0.0348 | 1752 B |
| 'Standard: d.HH:mm:ss.fff' | .NET 8.0 | 368.5408 ns | 1.7708 ns | 1.4787 ns | 370.8185 ns | 6.18 | 0.03 | 4 | 0.0362 | 1840 B |
| 'Human: single unit (5m)' | .NET 8.0 | 440.2367 ns | 1.8504 ns | 1.6403 ns | 442.6820 ns | 7.38 | 0.04 | 5 | 0.0339 | 1712 B |
| 'Human: two units (1h 30m)' | .NET 8.0 | 492.3350 ns | 5.4852 ns | 5.1308 ns | 499.0772 ns | 8.26 | 0.09 | 6 | 0.0353 | 1776 B |
| 'Human: full combo' \* | .NET 8.0 | 853.8517 ns | 8.0323 ns | 7.5134 ns | 865.4025 ns | 14.32 | 0.13 | 8 | 0.0401 | 2024 B |
| 'Human: decimals (1.5h 100ms)' | .NET 8.0 | 707.8297 ns | 5.2995 ns | 4.9572 ns | 715.4481 ns | 11.87 | 0.09 | 7 | 0.0372 | 1880 B |
| 'Invalid input' | .NET 8.0 | 59.6169 ns | 0.2654 ns | 0.2352 ns | 59.9797 ns | 1.00 | 0.01 | 2 | - | - |
| 'Null input' | .NET 8.0 | 0.9443 ns | 0.0370 ns | 0.0803 ns | 1.0528 ns | 0.02 | 0.00 | 1 | - | - |

\* full combo = "2 days 4 hours 15 minutes 30 seconds 500 milliseconds"
22 changes: 22 additions & 0 deletions benchmarks/DurationMancer.Benchmarks/Configuration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Exporters;
using BenchmarkDotNet.Jobs;

namespace DurationMancer.Benchmarks;

public class Configuration : ManualConfig
{
public Configuration()
{
AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithId(".NET 8"));
AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithId(".NET 9"));
AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithId(".NET 10"));

AddExporter(MarkdownExporter.GitHub);
AddExporter(HtmlExporter.Default);

AddColumn(StatisticColumn.P95);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<AssemblyAttribute>System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage</AssemblyAttribute>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\DurationMancer\DurationMancer.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using BenchmarkDotNet.Attributes;

namespace DurationMancer.Benchmarks;

[MemoryDiagnoser]
[RankColumn]
[Config(typeof(Configuration))]
public class DurationTimeParserBenchmarks
{
// --- Standard duration format inputs ---

[Benchmark(Description = "Standard: HH:mm:ss")]
public bool Standard_HHmmss()
=> DurationTimeParser.TryParse("12:30:45", out _);

[Benchmark(Description = "Standard: d.HH:mm:ss.fff")]
public bool Standard_Full()
=> DurationTimeParser.TryParse("3.08:15:30.250", out _);

// --- Human-readable format inputs ---

[Benchmark(Description = "Human: single unit (5m)")]
public bool Human_SingleUnit()
=> DurationTimeParser.TryParse("5m", out _);

[Benchmark(Description = "Human: two units (1h 30m)")]
public bool Human_TwoUnits()
=> DurationTimeParser.TryParse("1h 30m", out _);

[Benchmark(Description = "Human: full combo")]
public bool Human_FullCombo()
=> DurationTimeParser.TryParse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _);

[Benchmark(Description = "Human: decimals (1.5h 100ms)")]
public bool Human_Decimals()
=> DurationTimeParser.TryParse("1.5h 100ms", out _);

// --- Edge / failure cases ---

[Benchmark(Description = "Invalid input", Baseline = true)]
public bool Invalid()
=> DurationTimeParser.TryParse("not a duration", out _);

[Benchmark(Description = "Null input")]
public bool Null()
=> DurationTimeParser.TryParse(null, out _);
}
4 changes: 4 additions & 0 deletions benchmarks/DurationMancer.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using BenchmarkDotNet.Running;
using DurationMancer.Benchmarks;

BenchmarkRunner.Run<DurationTimeParserBenchmarks>();