diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..ea94d51
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,33 @@
+name: main
+
+on:
+ workflow_dispatch:
+ inputs:
+ package-version:
+ type: string
+ description: Package version
+ required: false
+ deploy:
+ type: boolean
+ description: Deploy package
+ required: false
+ default: false
+ push:
+ branches:
+ - prime
+ tags:
+ - "*"
+ pull_request:
+ branches:
+ - prime
+
+jobs:
+ main:
+ uses: Tyrrrz/.github/.github/workflows/nuget.yml@prime
+ with:
+ deploy: ${{ inputs.deploy || github.ref_type == 'tag' }}
+ package-version: ${{ inputs.package-version || (github.ref_type == 'tag' && github.ref_name) || format('0.0.0-ci-{0}', github.sha) }}
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }}
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..20d4f31
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# User-specific files
+.vs/
+.idea/
+*.suo
+*.user
+
+# Build results
+bin/
+obj/
+
+# Test results
+TestResults/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..0822769
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,31 @@
+
+
+ 0.0.0-dev
+ Tyrrrz
+ Copyright (C) Oleksii Holub
+ preview
+ enable
+ true
+ false
+
+
+
+
+ annotations
+
+
+
+
+ false
+ false
+
+
+
+ $(Company)
+ Collection of utilities and extensions for rapid .NET development
+ utils extensions utilities source
+ https://github.com/Tyrrrz/PowerKit
+ https://github.com/Tyrrrz/PowerKit/releases
+ MIT
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..04d12ca
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,15 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/License.txt b/License.txt
new file mode 100644
index 0000000..68c3d52
--- /dev/null
+++ b/License.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Oleksii Holub
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..5a40e48
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs
new file mode 100644
index 0000000..64a317b
--- /dev/null
+++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs
@@ -0,0 +1,54 @@
+using System;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class AggregateExceptionExtensionsTests
+{
+ [Fact]
+ public void TryGetSingle_Test()
+ {
+ // Arrange
+ var inner = new Exception("only");
+
+ // Act & assert
+ new AggregateException(inner).TryGetSingle().Should().BeSameAs(inner);
+ }
+
+ [Fact]
+ public void TryGetSingle_Multiple_Test()
+ {
+ // Act & assert
+ new AggregateException(new Exception("a"), new Exception("b"))
+ .TryGetSingle()
+ .Should()
+ .BeNull();
+ }
+
+ [Fact]
+ public void TryGetSingle_Nested_Test()
+ {
+ // Arrange
+ var leaf = new Exception("leaf");
+
+ // Act & assert
+ new AggregateException(new AggregateException(leaf))
+ .TryGetSingle()
+ .Should()
+ .BeSameAs(leaf);
+ }
+
+ [Fact]
+ public void TryGetSingle_NestedMultiple_Test()
+ {
+ // Act & assert
+ new AggregateException(
+ new AggregateException(new Exception("a"), new Exception("b"))
+ )
+ .TryGetSingle()
+ .Should()
+ .BeNull();
+ }
+}
diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs
new file mode 100644
index 0000000..fdba27c
--- /dev/null
+++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs
@@ -0,0 +1,63 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class AsyncEnumerableExtensionsTests
+{
+ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source)
+ {
+ foreach (var item in source)
+ {
+ await Task.Yield();
+ yield return item;
+ }
+ }
+
+ [Fact]
+ public async Task TakeAsync_Test()
+ {
+ // Act & assert
+ (await ToAsyncEnumerable([1, 2, 3, 4, 5]).TakeAsync(3).ToListAsync())
+ .Should()
+ .Equal(1, 2, 3);
+
+ (await ToAsyncEnumerable([1, 2, 3]).TakeAsync(0).ToListAsync())
+ .Should()
+ .BeEmpty();
+
+ (await ToAsyncEnumerable([1, 2, 3]).TakeAsync(10).ToListAsync())
+ .Should()
+ .Equal(1, 2, 3);
+ }
+
+ [Fact]
+ public async Task SelectManyAsync_Test()
+ {
+ // Act & assert
+ (await ToAsyncEnumerable(["ab", "cd"]).SelectManyAsync(s => s.ToCharArray()).ToListAsync())
+ .Should()
+ .Equal('a', 'b', 'c', 'd');
+ }
+
+ [Fact]
+ public async Task ToListAsync_Test()
+ {
+ // Act & assert
+ (await ToAsyncEnumerable([1, 2, 3]).ToListAsync())
+ .Should()
+ .Equal(1, 2, 3);
+ }
+
+ [Fact]
+ public async Task GetAwaiter_Test()
+ {
+ // Act & assert
+ (await ToAsyncEnumerable([10, 20, 30]))
+ .Should()
+ .Equal(10, 20, 30);
+ }
+}
diff --git a/PowerKit.Tests/ComparableExtensionsTests.cs b/PowerKit.Tests/ComparableExtensionsTests.cs
new file mode 100644
index 0000000..c66747c
--- /dev/null
+++ b/PowerKit.Tests/ComparableExtensionsTests.cs
@@ -0,0 +1,37 @@
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class ComparableExtensionsTests
+{
+ [Fact]
+ public void Clamp_Test()
+ {
+ // Act & assert
+ 5.Clamp(1, 10).Should().Be(5);
+ 1.Clamp(3, 10).Should().Be(3);
+ 20.Clamp(1, 7).Should().Be(7);
+ 3.14.Clamp(0.0, 3.0).Should().Be(3.0);
+ "banana".Clamp("apple", "cherry").Should().Be("banana");
+ }
+
+ [Fact]
+ public void Min_Test()
+ {
+ // Act & assert
+ 5.Min(3).Should().Be(3);
+ 2.Min(7).Should().Be(2);
+ 4.Min(4).Should().Be(4);
+ }
+
+ [Fact]
+ public void Max_Test()
+ {
+ // Act & assert
+ 5.Max(3).Should().Be(5);
+ 2.Max(7).Should().Be(7);
+ 4.Max(4).Should().Be(4);
+ }
+}
diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs
new file mode 100644
index 0000000..3ac1edd
--- /dev/null
+++ b/PowerKit.Tests/DisposableTests.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using PowerKit;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class DisposableTests
+{
+ [Fact]
+ public void Create_Test()
+ {
+ // Arrange
+ var invoked = false;
+ var disposable = Disposable.Create(() => invoked = true);
+
+ // Act & assert
+ invoked.Should().BeFalse();
+ disposable.Dispose();
+ invoked.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Merge_Test()
+ {
+ // Arrange
+ var order = new List();
+ var disposables = Enumerable
+ .Range(0, 3)
+ .Select(i => Disposable.Create(() => order.Add(i)))
+ .ToArray();
+
+ // Act
+ Disposable.Merge(disposables).Dispose();
+
+ // Assert
+ order.Should().Equal(0, 1, 2);
+ }
+
+ [Fact]
+ public void Merge_Exception_Test()
+ {
+ // Arrange
+ var disposed = new List();
+ var disposables = new[]
+ {
+ Disposable.Create(() => disposed.Add(0)),
+ Disposable.Create(() =>
+ {
+ disposed.Add(1);
+ throw new InvalidOperationException("fail");
+ }),
+ Disposable.Create(() => disposed.Add(2)),
+ };
+
+ // Act
+ var ex = Assert.Throws(() => Disposable.Merge(disposables).Dispose());
+
+ // Assert
+ disposed.Should().Equal(0, 1, 2);
+ ex.InnerExceptions.Should().ContainSingle();
+ ex.InnerExceptions[0].Should().BeOfType();
+ }
+}
diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs
new file mode 100644
index 0000000..c32dc15
--- /dev/null
+++ b/PowerKit.Tests/EnumerableExtensionsTests.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Linq;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class EnumerableExtensionsTests
+{
+ [Fact]
+ public void ToSingletonEnumerable_Test()
+ {
+ // Act & assert
+ 42.ToSingletonEnumerable().ToArray().Should().Equal(42);
+ "hello".ToSingletonEnumerable().ToArray().Should().Equal("hello");
+ ((string?)null).ToSingletonEnumerable().ToArray().Should().Equal((string?)null);
+ }
+
+ [Fact]
+ public void WhereNotNull_Test()
+ {
+ // Act & assert
+ new string?[] { "a", null, "b", null, "c" }.WhereNotNull().Should().Equal("a", "b", "c");
+ new int?[] { 1, null, 2, null, 3 }.WhereNotNull().Should().Equal(1, 2, 3);
+ Array.Empty().WhereNotNull().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhereNotNullOrEmpty_Test()
+ {
+ // Act & assert
+ new string?[] { "hello", null, "", "world" }
+ .WhereNotNullOrEmpty()
+ .Should()
+ .Equal("hello", "world");
+ new string?[] { "hello", " ", "", null }
+ .WhereNotNullOrEmpty()
+ .Should()
+ .Equal("hello", " ");
+ Array.Empty().WhereNotNullOrEmpty().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhereNotNullOrWhiteSpace_Test()
+ {
+ // Act & assert
+ new string?[] { "hello", null, " ", "", "world" }
+ .WhereNotNullOrWhiteSpace()
+ .Should()
+ .Equal("hello", "world");
+ }
+
+ [Fact]
+ public void FirstOrNull_Test()
+ {
+ // Act & assert
+ new[] { 5, 10, 15 }.FirstOrNull().Should().Be(5);
+ new[] { 42 }.FirstOrNull().Should().Be(42);
+ Array.Empty().FirstOrNull().Should().BeNull();
+ }
+
+ [Fact]
+ public void LastOrNull_Test()
+ {
+ // Act & assert
+ new[] { 5, 10, 15 }.LastOrNull().Should().Be(15);
+ new[] { 42 }.LastOrNull().Should().Be(42);
+ Array.Empty().LastOrNull().Should().BeNull();
+ }
+
+ [Fact]
+ public void ElementAtOrNull_Test()
+ {
+ // Act & assert
+ new[] { 10, 20, 30 }.ElementAtOrNull(1).Should().Be(20);
+ new[] { 10, 20, 30 }.ElementAtOrNull(0).Should().Be(10);
+ new[] { 10, 20, 30 }.ElementAtOrNull(10).Should().BeNull();
+ new[] { 10, 20, 30 }.ElementAtOrNull(-1).Should().BeNull();
+ Array.Empty().ElementAtOrNull(0).Should().BeNull();
+ }
+}
diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs
new file mode 100644
index 0000000..c3a46ad
--- /dev/null
+++ b/PowerKit.Tests/ExceptionExtensionsTests.cs
@@ -0,0 +1,66 @@
+using System;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class ExceptionExtensionsTests
+{
+ [Fact]
+ public void GetSelfAndDescendants_Test()
+ {
+ // Arrange
+ var ex = new Exception("root");
+
+ // Act & assert
+ ex.GetSelfAndDescendants().Should().Equal(ex);
+ }
+
+ [Fact]
+ public void GetSelfAndDescendants_Chain_Test()
+ {
+ // Arrange
+ var inner = new Exception("inner");
+ var outer = new Exception("outer", inner);
+
+ // Act & assert
+ outer.GetSelfAndDescendants().Should().Equal(outer, inner);
+ }
+
+ [Fact]
+ public void GetSelfAndDescendants_DeepChain_Test()
+ {
+ // Arrange
+ var leaf = new Exception("leaf");
+ var middle = new Exception("middle", leaf);
+ var root = new Exception("root", middle);
+
+ // Act & assert
+ root.GetSelfAndDescendants().Should().Equal(root, middle, leaf);
+ }
+
+ [Fact]
+ public void GetSelfAndDescendants_Aggregate_Test()
+ {
+ // Arrange
+ var inner1 = new Exception("inner1");
+ var inner2 = new Exception("inner2");
+ var aggregate = new AggregateException("aggregate", inner1, inner2);
+
+ // Act & assert
+ aggregate.GetSelfAndDescendants().Should().Equal(aggregate, inner1, inner2);
+ }
+
+ [Fact]
+ public void GetSelfAndDescendants_NestedAggregate_Test()
+ {
+ // Arrange
+ var leaf = new Exception("leaf");
+ var inner = new AggregateException("inner", leaf);
+ var root = new AggregateException("root", inner);
+
+ // Act & assert
+ root.GetSelfAndDescendants().Should().Equal(root, inner, leaf);
+ }
+}
diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs
new file mode 100644
index 0000000..ee9c76c
--- /dev/null
+++ b/PowerKit.Tests/FunctionalExtensionsTests.cs
@@ -0,0 +1,53 @@
+using System;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class FunctionalExtensionsTests
+{
+ [Fact]
+ public void Pipe_Test()
+ {
+ // Act & assert
+ 5.Pipe(x => x * 2).Should().Be(10);
+ "hello".Pipe(s => s.ToUpper()).Pipe(s => s + "!").Should().Be("HELLO!");
+ }
+
+ [Fact]
+ public void NullIf_Test()
+ {
+ // Act & assert
+ 0.NullIf(v => v == 0).Should().BeNull();
+ 5.NullIf(v => v == 0).Should().Be(5);
+ }
+
+ [Fact]
+ public void NullIfDefault_Test()
+ {
+ // Act & assert
+ 0.NullIfDefault().Should().BeNull();
+ 42.NullIfDefault().Should().Be(42);
+ Guid.Empty.NullIfDefault().Should().BeNull();
+ Guid.NewGuid().NullIfDefault().Should().NotBeNull();
+ }
+
+ [Fact]
+ public void NullIfEmpty_Test()
+ {
+ // Act & assert
+ "hello".NullIfEmpty().Should().Be("hello");
+ "".NullIfEmpty().Should().BeNull();
+ " ".NullIfEmpty().Should().Be(" ");
+ }
+
+ [Fact]
+ public void NullIfWhiteSpace_Test()
+ {
+ // Act & assert
+ "hello".NullIfWhiteSpace().Should().Be("hello");
+ " ".NullIfWhiteSpace().Should().BeNull();
+ "".NullIfWhiteSpace().Should().BeNull();
+ }
+}
diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs
new file mode 100644
index 0000000..44efd7d
--- /dev/null
+++ b/PowerKit.Tests/PathExtensionsTests.cs
@@ -0,0 +1,54 @@
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class PathExtensionsTests
+{
+ [Fact]
+ public void GetInvalidFileNameChars_Test()
+ {
+ // Act & assert
+ Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('/');
+ Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\\');
+ Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\0');
+ Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\x01');
+ Path.GetInvalidFileNameChars(crossPlatform: false)
+ .Should()
+ .BeEquivalentTo(Path.GetInvalidFileNameChars());
+ }
+
+ [Fact]
+ public void GetInvalidPathChars_Test()
+ {
+ // Act & assert
+ Path.GetInvalidPathChars(crossPlatform: true).Should().Contain('\0');
+ Path.GetInvalidPathChars(crossPlatform: true).Should().Contain('|');
+ Path.GetInvalidPathChars(crossPlatform: true).Should().NotContain('/');
+ Path.GetInvalidPathChars(crossPlatform: true).Should().NotContain('\\');
+ Path.GetInvalidPathChars(crossPlatform: false)
+ .Should()
+ .BeEquivalentTo(Path.GetInvalidPathChars());
+ }
+
+ [Fact]
+ public void EscapeFileName_Test()
+ {
+ // Act & assert
+ Path.EscapeFileName("hello world.txt").Should().Be("hello world.txt");
+ Path.EscapeFileName("a/b").Should().Be("a_b");
+ Path.EscapeFileName("a\\b").Should().Be("a_b");
+ Path.EscapeFileName("C:drive").Should().Be("C_drive");
+ Path.EscapeFileName("a\0b/c\\d:e*f?g\"h
+
+
+ net10.0
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs
new file mode 100644
index 0000000..5930c6d
--- /dev/null
+++ b/PowerKit.Tests/StreamExtensionsTests.cs
@@ -0,0 +1,48 @@
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Gress;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class StreamExtensionsTests
+{
+ [Fact]
+ public async Task CopyToAsync_AutoFlush_Test()
+ {
+ // Arrange
+ var data = new byte[] { 1, 2, 3, 4, 5 };
+ using var source = new MemoryStream(data);
+ using var destination = new MemoryStream();
+
+ // Act
+ await source.CopyToAsync(destination, autoFlush: true);
+
+ // Assert
+ destination.ToArray().Should().Equal(data);
+ }
+
+ [Fact]
+ public async Task CopyToAsync_Progress_Test()
+ {
+ // Arrange
+ var data = new byte[1024];
+ using var source = new MemoryStream(data);
+ using var destination = new MemoryStream();
+
+ var progress = new ProgressCollector();
+
+ // Act
+ await source.CopyToAsync(destination, progress: progress);
+
+ // Assert
+ var reports = progress.GetValues().ToArray();
+ reports.Should().NotBeEmpty();
+ reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0));
+ reports[^1].Should().BeApproximately(1.0, precision: 1e-5);
+ }
+
+}
diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs
new file mode 100644
index 0000000..a75cf84
--- /dev/null
+++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs
@@ -0,0 +1,31 @@
+using System.Text;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class StringBuilderExtensionsTests
+{
+ [Fact]
+ public void AppendIfNotEmpty_Test()
+ {
+ // Act & assert
+ new StringBuilder().AppendIfNotEmpty(',').ToString().Should().Be("");
+ new StringBuilder("hello").AppendIfNotEmpty(',').ToString().Should().Be("hello,");
+ new StringBuilder("a").AppendIfNotEmpty(',').Append("b").ToString().Should().Be("a,b");
+ }
+
+ [Fact]
+ public void Trim_Test()
+ {
+ // Act & assert
+ new StringBuilder(" hello ").Trim().ToString().Should().Be("hello");
+ new StringBuilder(" hello").Trim().ToString().Should().Be("hello");
+ new StringBuilder("hello ").Trim().ToString().Should().Be("hello");
+ new StringBuilder("hello").Trim().ToString().Should().Be("hello");
+ new StringBuilder().Trim().ToString().Should().Be("");
+ new StringBuilder(" ").Trim().ToString().Should().Be("");
+ new StringBuilder(" hello ").Trim().Append("!").ToString().Should().Be("hello!");
+ }
+}
diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs
new file mode 100644
index 0000000..a60e97a
--- /dev/null
+++ b/PowerKit.Tests/StringExtensionsTests.cs
@@ -0,0 +1,87 @@
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class StringExtensionsTests
+{
+ [Fact]
+ public void SubstringUntil_Test()
+ {
+ // Act & assert
+ "hello world".SubstringUntil(" ").Should().Be("hello");
+ "hello".SubstringUntil("x").Should().Be("hello");
+ "xhello".SubstringUntil("x").Should().Be("");
+ }
+
+ [Fact]
+ public void SubstringUntilLast_Test()
+ {
+ // Act & assert
+ "hello world foo".SubstringUntilLast(" ").Should().Be("hello world");
+ "hello".SubstringUntilLast("x").Should().Be("hello");
+ "a.b.c".SubstringUntilLast(".").Should().Be("a.b");
+ }
+
+ [Fact]
+ public void SubstringAfter_Test()
+ {
+ // Act & assert
+ "hello world".SubstringAfter(" ").Should().Be("world");
+ "hello".SubstringAfter("x").Should().Be("");
+ "hellox".SubstringAfter("x").Should().Be("");
+ }
+
+ [Fact]
+ public void SubstringAfterLast_Test()
+ {
+ // Act & assert
+ "hello world foo".SubstringAfterLast(" ").Should().Be("foo");
+ "hello".SubstringAfterLast("x").Should().Be("");
+ "a.b.c".SubstringAfterLast(".").Should().Be("c");
+ }
+
+ [Fact]
+ public void Truncate_Test()
+ {
+ // Act & assert
+ "hi".Truncate(10).Should().Be("hi");
+ "hello".Truncate(5).Should().Be("hello");
+ "hello".Truncate(3).Should().Be("hel");
+ }
+
+ [Fact]
+ public void SeparateWords_Test()
+ {
+ // Act & assert
+ "HelloWorld".SeparateWords(' ').Should().Be("Hello World");
+ "Hello".SeparateWords(' ').Should().Be("Hello");
+ "hello".SeparateWords(' ').Should().Be("hello");
+ "".SeparateWords(' ').Should().Be("");
+ "FooBarBaz".SeparateWords(' ').Should().Be("Foo Bar Baz");
+ "FooBarBaz".SeparateWords('-').Should().Be("Foo-Bar-Baz");
+ }
+
+ [Fact]
+ public void ToKebabCase_Test()
+ {
+ // Act & assert
+ "HelloWorld".ToKebabCase().Should().Be("hello-world");
+ "FooBarBaz".ToKebabCase().Should().Be("foo-bar-baz");
+ "Hello".ToKebabCase().Should().Be("hello");
+ "hello".ToKebabCase().Should().Be("hello");
+ "".ToKebabCase().Should().Be("");
+ }
+
+ [Fact]
+ public void ToSnakeCase_Test()
+ {
+ // Act & assert
+ "HelloWorld".ToSnakeCase().Should().Be("hello_world");
+ "FooBarBaz".ToSnakeCase().Should().Be("foo_bar_baz");
+ "Hello".ToSnakeCase().Should().Be("hello");
+ "hello".ToSnakeCase().Should().Be("hello");
+ "".ToSnakeCase().Should().Be("");
+ }
+}
diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs
new file mode 100644
index 0000000..2ec67a3
--- /dev/null
+++ b/PowerKit.Tests/TextReaderExtensionsTests.cs
@@ -0,0 +1,49 @@
+using System.IO;
+using System.Threading.Tasks;
+using FluentAssertions;
+using PowerKit.Extensions;
+using Xunit;
+
+namespace PowerKit.Tests;
+
+public class TextReaderExtensionsTests
+{
+ [Fact]
+ public async Task ReadLinesAsync_Test()
+ {
+ // Arrange
+ using var reader = new StringReader("line1\nline2\nline3");
+
+ // Act
+ var result = await reader.ReadLinesAsync().ToListAsync();
+
+ // Assert
+ result.Should().Equal("line1", "line2", "line3");
+ }
+
+ [Fact]
+ public async Task ReadLinesAsync_Empty_Test()
+ {
+ // Arrange
+ using var reader = new StringReader("");
+
+ // Act
+ var result = await reader.ReadLinesAsync().ToListAsync();
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ReadLinesAsync_SingleLine_Test()
+ {
+ // Arrange
+ using var reader = new StringReader("hello");
+
+ // Act
+ var result = await reader.ReadLinesAsync().ToListAsync();
+
+ // Assert
+ result.Should().Equal("hello");
+ }
+}
diff --git a/PowerKit.slnx b/PowerKit.slnx
new file mode 100644
index 0000000..eb54d4e
--- /dev/null
+++ b/PowerKit.slnx
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PowerKit/Disposable.cs b/PowerKit/Disposable.cs
new file mode 100644
index 0000000..eb2179f
--- /dev/null
+++ b/PowerKit/Disposable.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerKit;
+
+internal partial class Disposable(Action dispose) : IDisposable
+{
+ public void Dispose() => dispose();
+}
+
+internal partial class Disposable
+{
+ public static IDisposable Null { get; } = Create(() => { });
+
+ public static IDisposable Create(Action dispose) => new Disposable(dispose);
+
+ public static IDisposable Merge(params IEnumerable disposables) =>
+ Create(() =>
+ {
+ List? exceptions = null;
+
+ foreach (var disposable in disposables)
+ {
+ try
+ {
+ disposable.Dispose();
+ }
+ catch (Exception ex)
+ {
+ (exceptions ??= []).Add(ex);
+ }
+ }
+
+ if (exceptions?.Count > 0)
+ {
+ throw new AggregateException(exceptions);
+ }
+ });
+}
diff --git a/PowerKit/Extensions/AggregateExceptionExtensions.cs b/PowerKit/Extensions/AggregateExceptionExtensions.cs
new file mode 100644
index 0000000..c0d0fea
--- /dev/null
+++ b/PowerKit/Extensions/AggregateExceptionExtensions.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace PowerKit.Extensions;
+
+internal static class AggregateExceptionExtensions
+{
+ extension(AggregateException exception)
+ {
+ public Exception? TryGetSingle() =>
+ exception.Flatten().InnerExceptions is [var single] ? single : null;
+ }
+}
diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs
new file mode 100644
index 0000000..b3ac565
--- /dev/null
+++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PowerKit.Extensions;
+
+internal static class AsyncEnumerableExtensions
+{
+ extension(IAsyncEnumerable source)
+ {
+ public async IAsyncEnumerable TakeAsync(
+ int count,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
+ {
+ if (count <= 0)
+ yield break;
+
+ var currentCount = 0;
+
+ await using var enumerator = source.GetAsyncEnumerator(cancellationToken);
+
+ while (
+ currentCount < count
+ && await enumerator.MoveNextAsync().ConfigureAwait(false)
+ )
+ {
+ yield return enumerator.Current;
+ currentCount++;
+ }
+ }
+
+ public async IAsyncEnumerable SelectManyAsync(
+ Func> transform,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
+ {
+ await foreach (
+ var item in source
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false)
+ )
+ {
+ foreach (var result in transform(item))
+ {
+ yield return result;
+ }
+ }
+ }
+
+ public async ValueTask> ToListAsync(
+ CancellationToken cancellationToken = default
+ )
+ {
+ var list = new List();
+
+ await foreach (
+ var item in source
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false)
+ )
+ {
+ list.Add(item);
+ }
+
+ return list;
+ }
+
+ public ValueTaskAwaiter> GetAwaiter() => source.ToListAsync().GetAwaiter();
+ }
+}
diff --git a/PowerKit/Extensions/ComparableExtensions.cs b/PowerKit/Extensions/ComparableExtensions.cs
new file mode 100644
index 0000000..b8e0639
--- /dev/null
+++ b/PowerKit/Extensions/ComparableExtensions.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace PowerKit.Extensions;
+
+internal static class ComparableExtensions
+{
+ extension(T value) where T : IComparable
+ {
+ public T Clamp(T min, T max)
+ {
+ if (value.CompareTo(min) < 0)
+ return min;
+
+ if (value.CompareTo(max) > 0)
+ return max;
+
+ return value;
+ }
+
+ public T Min(T other) => value.CompareTo(other) <= 0 ? value : other;
+
+ public T Max(T other) => value.CompareTo(other) >= 0 ? value : other;
+ }
+}
diff --git a/PowerKit/Extensions/EnumerableExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs
new file mode 100644
index 0000000..378c712
--- /dev/null
+++ b/PowerKit/Extensions/EnumerableExtensions.cs
@@ -0,0 +1,107 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PowerKit.Extensions;
+
+internal static class EnumerableExtensions
+{
+ extension(T obj)
+ {
+ public IEnumerable ToSingletonEnumerable()
+ {
+ yield return obj;
+ }
+ }
+
+ extension(IEnumerable source)
+ where T : class
+ {
+ public IEnumerable WhereNotNull()
+ {
+ foreach (var item in source)
+ {
+ if (item is not null)
+ {
+ yield return item;
+ }
+ }
+ }
+ }
+
+ extension(IEnumerable source)
+ where T : struct
+ {
+ public IEnumerable WhereNotNull()
+ {
+ foreach (var item in source)
+ {
+ if (item is not null)
+ {
+ yield return item.Value;
+ }
+ }
+ }
+ }
+
+ extension(IEnumerable source)
+ {
+ public IEnumerable WhereNotNullOrEmpty()
+ {
+ foreach (var item in source)
+ {
+ if (!string.IsNullOrEmpty(item))
+ {
+ yield return item!;
+ }
+ }
+ }
+
+ public IEnumerable WhereNotNullOrWhiteSpace()
+ {
+ foreach (var item in source)
+ {
+ if (!string.IsNullOrWhiteSpace(item))
+ {
+ yield return item!;
+ }
+ }
+ }
+ }
+
+ extension(IEnumerable source)
+ where T : struct
+ {
+ public T? FirstOrNull()
+ {
+ foreach (var item in source)
+ {
+ return item;
+ }
+
+ return null;
+ }
+
+ public T? LastOrNull()
+ {
+ if (source is IReadOnlyList list)
+ {
+ return list.Count > 0 ? list[list.Count - 1] : null;
+ }
+
+ var last = default(T?);
+
+ foreach (var item in source)
+ {
+ last = item;
+ }
+
+ return last;
+ }
+
+ public T? ElementAtOrNull(int index)
+ {
+ var list = source as IReadOnlyList ?? source.ToArray();
+ return index >= 0 && index < list.Count ? list[index] : null;
+ }
+ }
+}
diff --git a/PowerKit/Extensions/ExceptionExtensions.cs b/PowerKit/Extensions/ExceptionExtensions.cs
new file mode 100644
index 0000000..ab721f7
--- /dev/null
+++ b/PowerKit/Extensions/ExceptionExtensions.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerKit.Extensions;
+
+internal static class ExceptionExtensions
+{
+ extension(Exception exception)
+ {
+ public IReadOnlyList GetSelfAndDescendants()
+ {
+ static void PopulateDescendants(Exception ex, ICollection result)
+ {
+ if (ex is AggregateException aggregateException)
+ {
+ foreach (var innerException in aggregateException.InnerExceptions)
+ {
+ result.Add(innerException);
+ PopulateDescendants(innerException, result);
+ }
+ }
+ else if (ex.InnerException is not null)
+ {
+ result.Add(ex.InnerException);
+ PopulateDescendants(ex.InnerException, result);
+ }
+ }
+
+ var result = new List { exception };
+ PopulateDescendants(exception, result);
+ return result;
+ }
+ }
+}
diff --git a/PowerKit/Extensions/FunctionalExtensions.cs b/PowerKit/Extensions/FunctionalExtensions.cs
new file mode 100644
index 0000000..5e40566
--- /dev/null
+++ b/PowerKit/Extensions/FunctionalExtensions.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerKit.Extensions;
+
+internal static class FunctionalExtensions
+{
+ extension(TIn input)
+ {
+ public TOut Pipe(Func transform) => transform(input);
+ }
+
+ extension(T value)
+ where T : struct
+ {
+ public T? NullIf(Func predicate) => !predicate(value) ? value : null;
+
+ public T? NullIfDefault() =>
+ value.NullIf(v => EqualityComparer.Default.Equals(v, default));
+ }
+
+ extension(string value)
+ {
+ public string? NullIfEmpty() => !string.IsNullOrEmpty(value) ? value : null;
+
+ public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(value) ? value : null;
+ }
+}
diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs
new file mode 100644
index 0000000..003ddc7
--- /dev/null
+++ b/PowerKit/Extensions/PathExtensions.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace PowerKit.Extensions;
+
+file static class PathEx
+{
+ // Characters that are invalid in file names across all major filesystems
+ // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what
+ // the OS-specific Path.GetInvalidFileNameChars() returns.
+ // This is useful when working with files that may be accessed from
+ // different operating systems, such as NTFS drives on Linux.
+ public static readonly char[] CrossPlatformInvalidFileNameChars =
+ [
+ '\0', // Null character - invalid on all filesystems
+ '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters -
+ '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', // invalid on Windows
+ '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', // (NTFS/FAT32)
+ '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F',
+ '/', // Path separator on Unix and Windows
+ '\\', // Path separator on Windows
+ ':', // Reserved on Windows (drive letters, NTFS streams)
+ '*', // Wildcard on Windows
+ '?', // Wildcard on Windows
+ '"', // Reserved on Windows
+ '<', // Redirection on Windows
+ '>', // Redirection on Windows
+ '|', // Pipe on Windows
+ ];
+
+ // Path chars are the same as file name chars, except path separators
+ // and the colon (drive letter separator) are valid in paths.
+ public static readonly char[] CrossPlatformInvalidPathChars =
+ CrossPlatformInvalidFileNameChars
+ .Where(ch => ch != '/' && ch != '\\' && ch != ':')
+ .ToArray();
+}
+
+internal static class PathExtensions
+{
+ extension(Path)
+ {
+ public static char[] GetInvalidFileNameChars(bool crossPlatform) =>
+ crossPlatform
+ ? PathEx.CrossPlatformInvalidFileNameChars
+ : Path.GetInvalidFileNameChars();
+
+ public static char[] GetInvalidPathChars(bool crossPlatform) =>
+ crossPlatform
+ ? PathEx.CrossPlatformInvalidPathChars
+ : Path.GetInvalidPathChars();
+
+ public static string EscapeFileName(string fileName, bool crossPlatform = true)
+ {
+ var invalidChars = new HashSet(Path.GetInvalidFileNameChars(crossPlatform));
+ var buffer = new StringBuilder(fileName.Length);
+
+ foreach (var ch in fileName)
+ {
+ buffer.Append(!invalidChars.Contains(ch) ? ch : '_');
+ }
+
+ // File names cannot end with a dot or whitespace (invalid on Windows, ambiguous on other filesystems)
+ while (buffer.Length > 0 && (buffer[buffer.Length - 1] == '.' || char.IsWhiteSpace(buffer[buffer.Length - 1])))
+ {
+ buffer.Remove(buffer.Length - 1, 1);
+ }
+
+ return buffer.ToString();
+ }
+ }
+}
diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs
new file mode 100644
index 0000000..e392e83
--- /dev/null
+++ b/PowerKit/Extensions/StreamExtensions.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PowerKit.Extensions;
+
+internal static class StreamExtensions
+{
+ extension(Stream source)
+ {
+ public async Task CopyToAsync(
+ Stream destination,
+ bool autoFlush,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var buffer = MemoryPool.Shared.Rent(81920);
+
+ while (true)
+ {
+ var bytesRead = await source
+ .ReadAsync(buffer.Memory, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (bytesRead <= 0)
+ {
+ break;
+ }
+
+ await destination
+ .WriteAsync(buffer.Memory[..bytesRead], cancellationToken)
+ .ConfigureAwait(false);
+
+ if (autoFlush)
+ {
+ await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ public async ValueTask CopyToAsync(
+ Stream destination,
+ long contentLength,
+ IProgress? progress,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var buffer = MemoryPool.Shared.Rent(81920);
+
+ var totalBytesRead = 0L;
+
+ while (true)
+ {
+ var bytesRead = await source
+ .ReadAsync(buffer.Memory, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (bytesRead <= 0)
+ {
+ break;
+ }
+
+ await destination
+ .WriteAsync(buffer.Memory[..bytesRead], cancellationToken)
+ .ConfigureAwait(false);
+
+ totalBytesRead += bytesRead;
+
+ if (progress is not null && contentLength > 0)
+ {
+ progress.Report(1.0 * totalBytesRead / contentLength);
+ }
+ }
+ }
+
+ public async ValueTask CopyToAsync(
+ Stream destination,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var contentLength = source.CanSeek ? source.Length : -1;
+ await source
+ .CopyToAsync(destination, contentLength, progress, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/PowerKit/Extensions/StringBuilderExtensions.cs b/PowerKit/Extensions/StringBuilderExtensions.cs
new file mode 100644
index 0000000..d8e73df
--- /dev/null
+++ b/PowerKit/Extensions/StringBuilderExtensions.cs
@@ -0,0 +1,39 @@
+using System.Text;
+
+namespace PowerKit.Extensions;
+
+internal static class StringBuilderExtensions
+{
+ extension(StringBuilder builder)
+ {
+ public StringBuilder AppendIfNotEmpty(char value) =>
+ builder.Length > 0 ? builder.Append(value) : builder;
+
+ public StringBuilder Trim()
+ {
+ var start = 0;
+ while (start < builder.Length && char.IsWhiteSpace(builder[start]))
+ {
+ start++;
+ }
+
+ var end = builder.Length - 1;
+ while (end >= start && char.IsWhiteSpace(builder[end]))
+ {
+ end--;
+ }
+
+ if (end < builder.Length - 1)
+ {
+ builder.Remove(end + 1, builder.Length - end - 1);
+ }
+
+ if (start > 0)
+ {
+ builder.Remove(0, start);
+ }
+
+ return builder;
+ }
+ }
+}
diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs
new file mode 100644
index 0000000..2320da9
--- /dev/null
+++ b/PowerKit/Extensions/StringExtensions.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Text;
+
+namespace PowerKit.Extensions;
+
+internal static class StringExtensions
+{
+ extension(string str)
+ {
+ public string SubstringUntil(
+ string sub,
+ StringComparison comparison = StringComparison.Ordinal
+ ) =>
+ str.IndexOf(sub, comparison) switch
+ {
+ >= 0 and var index => str[..index],
+ _ => str,
+ };
+
+ public string SubstringUntilLast(
+ string sub,
+ StringComparison comparison = StringComparison.Ordinal
+ ) =>
+ str.LastIndexOf(sub, comparison) switch
+ {
+ >= 0 and var index => str[..index],
+ _ => str,
+ };
+
+ public string SubstringAfter(
+ string sub,
+ StringComparison comparison = StringComparison.Ordinal
+ ) =>
+ str.IndexOf(sub, comparison) switch
+ {
+ >= 0 and var index => str[(index + sub.Length)..],
+ _ => "",
+ };
+
+ public string SubstringAfterLast(
+ string sub,
+ StringComparison comparison = StringComparison.Ordinal
+ ) =>
+ str.LastIndexOf(sub, comparison) switch
+ {
+ >= 0 and var index => str[(index + sub.Length)..],
+ _ => "",
+ };
+
+ public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
+
+ public string SeparateWords(char separator)
+ {
+ var builder = new StringBuilder(str.Length * 2);
+
+ foreach (var ch in str)
+ {
+ if (char.IsUpper(ch) && builder.Length > 0)
+ {
+ builder.Append(separator);
+ }
+
+ builder.Append(ch);
+ }
+
+ return builder.ToString();
+ }
+
+ public string ToKebabCase() => str.SeparateWords('-').ToLowerInvariant();
+
+ public string ToSnakeCase() => str.SeparateWords('_').ToLowerInvariant();
+ }
+}
diff --git a/PowerKit/Extensions/TextReaderExtensions.cs b/PowerKit/Extensions/TextReaderExtensions.cs
new file mode 100644
index 0000000..08e1323
--- /dev/null
+++ b/PowerKit/Extensions/TextReaderExtensions.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace PowerKit.Extensions;
+
+internal static class TextReaderExtensions
+{
+ extension(TextReader reader)
+ {
+ public async IAsyncEnumerable ReadLinesAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
+ {
+ while (
+ await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line
+ )
+ {
+ yield return line;
+ }
+ }
+ }
+}
diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj
new file mode 100644
index 0000000..eddfde8
--- /dev/null
+++ b/PowerKit/PowerKit.csproj
@@ -0,0 +1,35 @@
+
+
+ netstandard2.0
+ true
+
+
+
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..512142d
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+}