diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml
new file mode 100644
index 0000000..15fbea2
--- /dev/null
+++ b/.github/workflows/benchmarks.yaml
@@ -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
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 329b465..53cf57c 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -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 }}
@@ -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
@@ -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"
@@ -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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 871dfbc..574c688 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,7 @@
+
@@ -14,4 +15,4 @@
-
+
\ No newline at end of file
diff --git a/DurationMancer.slnx b/DurationMancer.slnx
index 40c5c69..688880b 100644
--- a/DurationMancer.slnx
+++ b/DurationMancer.slnx
@@ -1,4 +1,5 @@
+
diff --git a/README.md b/README.md
index b95d745..963d3c6 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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"
diff --git a/benchmarks/DurationMancer.Benchmarks/Configuration.cs b/benchmarks/DurationMancer.Benchmarks/Configuration.cs
new file mode 100644
index 0000000..f19b0eb
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/Configuration.cs
@@ -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);
+ }
+}
diff --git a/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj
new file mode 100644
index 0000000..46ba2f3
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ false
+ false
+ System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage
+
+
+
+
+
+
+
+
+
+
+
diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs
new file mode 100644
index 0000000..7199181
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs
@@ -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 _);
+}
diff --git a/benchmarks/DurationMancer.Benchmarks/Program.cs b/benchmarks/DurationMancer.Benchmarks/Program.cs
new file mode 100644
index 0000000..69766a6
--- /dev/null
+++ b/benchmarks/DurationMancer.Benchmarks/Program.cs
@@ -0,0 +1,4 @@
+using BenchmarkDotNet.Running;
+using DurationMancer.Benchmarks;
+
+BenchmarkRunner.Run();