diff --git a/.well-known/agent-skills/csharpessentials-maybe/SKILL.md b/.well-known/agent-skills/csharpessentials-maybe/SKILL.md index 607969f..79f42d6 100644 --- a/.well-known/agent-skills/csharpessentials-maybe/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-maybe/SKILL.md @@ -1,6 +1,6 @@ --- name: csharpessentials-maybe -description: Use when representing optional values explicitly — Maybe as a null-safe container, Maybe.From() for creation, HasValue/HasNoValue, Map/Bind chaining, Match for consumption, and ToMaybeResult() to bridge into the Result pattern. +description: Use when representing optional values explicitly — Maybe as a null-safe container, Maybe.From()/FromTry() for creation, HasValue/HasNoValue, Map/Bind chaining, TapNone for None-side effects, Match for consumption, and ToMaybeResult() to bridge into the Result pattern. --- # CSharpEssentials.Maybe @@ -39,6 +39,14 @@ string val = maybe.GetValueOrDefault("fallback"); string val = maybe.GetValueOrThrow(); // throws if None ``` +## Exception-safe Creation + +```csharp +// Returns None if the factory throws — never propagates the exception +Maybe n = Maybe.FromTry(() => int.Parse(input)); +Maybe u = Maybe.FromTry(() => JsonSerializer.Deserialize(json)); +``` + ## Pattern Match ```csharp @@ -60,6 +68,24 @@ Maybe
address = Maybe.From(user) .Bind(u => Maybe.From(u?.Address)); ``` +## None-side Effects + +```csharp +// TapNone — runs only when None; returns the same Maybe unchanged +maybe.TapNone(() => logger.LogWarning("Value was absent")); + +// Async — instance and extension variants +await maybe.TapNoneAsync(async () => await NotifyAsync()); +await GetMaybeAsync().TapNoneAsync(() => fallback()); + +// GetValueOrElse — lazy factory, only called when None +int v = maybe.GetValueOrElse(() => ComputeExpensiveDefault()); + +// OrElse — lazy Maybe chain, alias for Or(Func>) +Maybe cfg = GetCached().OrElse(() => LoadFromDisk()); +await GetCached().OrElseAsync(() => Task.FromResult(LoadFromDisk())); +``` + ## Bridge to Result ```csharp diff --git a/.well-known/agent-skills/csharpessentials-results/SKILL.md b/.well-known/agent-skills/csharpessentials-results/SKILL.md index 3f84554..6a912bf 100644 --- a/.well-known/agent-skills/csharpessentials-results/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-results/SKILL.md @@ -108,6 +108,24 @@ Result safe = Result.Try(() => int.Parse(input), ex => Error.Exception(ex)) Result data = await Result.TryAsync(() => _db.GetAsync(id), ex => Error.Exception(ex)); ``` +## Conditional Side Effects + +```csharp +// TapIf — predicate-gated tap, only fires when Success AND predicate is true +result.TapIf(v => v > 0, v => Audit(v)); + +// Bool condition shorthand +result.TapIf(featureEnabled, v => Track(v)); + +// Async — instance methods +await result.TapIfAsync(v => v > 0, async v => await AuditAsync(v)); +await result.TapIfAsync(isEnabled, async v => await TrackAsync(v)); + +// Extension variants — Task> and ValueTask> +await GetResultAsync().TapIfAsync(v => v > 0, v => Enqueue(v)); +await GetValueTaskResultAsync().TapIfAsync(true, async v => await LogAsync(v)); +``` + ## Best Practices - Never access `.Value` without checking `.IsSuccess` first diff --git a/.well-known/agent-skills/csharpessentials-rules/SKILL.md b/.well-known/agent-skills/csharpessentials-rules/SKILL.md index 6da2f50..fdc2349 100644 --- a/.well-known/agent-skills/csharpessentials-rules/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-rules/SKILL.md @@ -220,6 +220,34 @@ result.Match( --- +## Predicate Factories + +Create `IRule` or `IAsyncRule` directly from a predicate — no class needed. + +```csharp +// Static error +IRule rule = RuleEngine.FromPredicate( + x => x > 0, + Error.Validation("Value.Negative", "Must be positive")); + +// Error factory — error message references the failing context +IRule rule = RuleEngine.FromPredicate( + s => s.Length >= 3, + s => Error.Validation("String.TooShort", $"'{s}' must be at least 3 characters")); + +// Async predicate (e.g., DB uniqueness check) +IAsyncRule rule = RuleEngine.FromPredicateAsync( + async s => await _db.IsUniqueAsync(s), + s => Error.Conflict("Name.Taken", $"'{s}' is already taken")); + +// Evaluate and compose normally +Result r = RuleEngine.Evaluate(rule, context); +Result composed = RuleEngine.Evaluate( + new IRuleBase[] { ruleA, ruleB }.And(), value); +``` + +--- + ## Best Practices - `array.And()` collects **all** failures; `array.Linear()` stops at the **first** failure diff --git a/.well-known/agent-skills/csharpessentials-these/SKILL.md b/.well-known/agent-skills/csharpessentials-these/SKILL.md new file mode 100644 index 0000000..34b7654 --- /dev/null +++ b/.well-known/agent-skills/csharpessentials-these/SKILL.md @@ -0,0 +1,112 @@ +--- +name: csharpessentials-these +description: Use when modeling partial success — These holds Left (error only), Right (value only), or Both (error + value), enabling scenarios where a result can partially succeed while carrying warnings. Use FromResult to bridge from Result, ToResult/ToResultLenient to bridge back, and Partition to split collections. +--- + +# CSharpEssentials.These + +`These` is a three-state discriminated union: Left (failure), Right (success), or Both (partial success with warning). Unlike `Result`, the Both state lets you carry a value AND an error simultaneously. + +## Installation + +```bash +dotnet add package CSharpEssentials.These +``` + +## Namespace + +```csharp +using CSharpEssentials.These; +using CSharpEssentials.Errors; // for Error type in bridge methods +``` + +## Creating These + +```csharp +These failure = These.Left("error"); +These success = These.Right(42); +These partial = These.Both("warning", 42); +``` + +## Checking State + +```csharp +bool isErr = these.IsLeft; +bool isOk = these.IsRight; +bool isBoth = these.IsBoth; + +// Safe access via Maybe +Maybe value = these.GetRight(); // None when IsLeft +Maybe error = these.GetLeft(); // None when IsRight +``` + +## Pattern Match + +```csharp +string msg = these.Match( + onLeft: e => $"Error: {e}", + onRight: v => $"Value: {v}", + onBoth: (e, v) => $"Partial: {v} (warning: {e})"); +``` + +## Transforming + +```csharp +// Map transforms the right value; Left passes through unchanged +These doubled = these.Map(x => x * 2); + +// MapLeft transforms the error; Right passes through unchanged +These upper = these.MapLeft(e => e.ToUpper()); + +// FlatMap chains — Both state is unwrapped (loses the error side) +These chained = these.FlatMap(x => These.Right(x.ToString())); +``` + +## Side Effects + +```csharp +// Tap fires on Right or Both +these.Tap(v => logger.Log($"Got {v}")); + +// TapLeft fires on Left or Both +these.TapLeft(e => logger.LogWarning(e)); +``` + +## Bridge to/from Result + +```csharp +// Result → These +These these = TheseExtensions.FromResult(result); + +// These → Result (Both = failure) +Result strict = these.ToResult(); + +// These → Result (Both = success, discards error side) +Result lenient = these.ToResultLenient(); +``` + +## Partition Collections + +```csharp +var (lefts, rights, boths) = items.Partition(); +// lefts → IReadOnlyList +// rights → IReadOnlyList +// boths → IReadOnlyList<(TError, TValue)> +``` + +## When to Use + +| Scenario | Use | +|----------|-----| +| Operation must fully succeed or fail | `Result` | +| Value may or may not exist | `Maybe` | +| Partial success with a warning to propagate | `These` | +| Collecting errors while continuing | `These` with Both | + +## Best Practices + +- Prefer `Match()` over checking `IsLeft`/`IsRight`/`IsBoth` separately — exhaustive and compiler-safe +- `FlatMap` loses the Both state — use it only when the warning from the prior step can be discarded +- `ToResultLenient()` is the lenient bridge: Both → success (value wins, error side discarded) +- `ToResult()` is the strict bridge: Both → failure +- Avoid using `These` as a general-purpose error type — use `Result` for that; `These` is for partial-success semantics diff --git a/.well-known/agent-skills/csharpessentials-time/SKILL.md b/.well-known/agent-skills/csharpessentials-time/SKILL.md index fde9e81..b2b38b2 100644 --- a/.well-known/agent-skills/csharpessentials-time/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-time/SKILL.md @@ -1,6 +1,6 @@ --- name: csharpessentials-time -description: Use when you need testable time — IDateTimeProvider wraps .NET's TimeProvider so production code uses TimeProvider.System while tests use FakeTimeProvider to freeze/advance the clock; also provides .ToDateOnly() and .ToTimeOnly() DateTime extension methods. +description: Use when you need testable time — IDateTimeProvider wraps clock access so production code uses DateTimeProvider while tests use the built-in FakeDateTimeProvider to freeze/advance/set the clock; also provides .ToDateOnly() and .ToTimeOnly() DateTime extension methods. --- # CSharpEssentials.Time @@ -69,24 +69,27 @@ public class OrderService --- -## Test with FakeTimeProvider +## Test with FakeDateTimeProvider + +`CSharpEssentials.Time` ships a built-in `FakeDateTimeProvider` — no extra NuGet package needed. ```csharp -// Install: dotnet add package Microsoft.Extensions.TimeProvider.Testing -using Microsoft.Extensions.Time.Testing; +var fixed = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero); +var fake = new FakeDateTimeProvider(fixed); -var fake = new FakeTimeProvider(); -fake.SetUtcNow(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)); +// Inject as IDateTimeProvider +var svc = new OrderService(fake); -var provider = new DateTimeProvider(fake); -var svc = new OrderService(provider); +// Advance the clock without Thread.Sleep +fake.Advance(TimeSpan.FromHours(2)); +fake.UtcNow // → 2025-01-15 12:00:00 -var order = svc.Create(cart); -Assert.Equal(new DateOnly(2025, 1, 15), order.DueDate.AddDays(-7)); +// Jump to a specific instant +fake.SetTime(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); -// Advance the clock -fake.Advance(TimeSpan.FromHours(2)); -Assert.Equal(new TimeOnly(12, 0, 0), provider.UtcNowTime); +// NET6+ only +DateOnly date = fake.UtcNowDate; +TimeOnly time = fake.UtcNowTime; ``` --- diff --git a/CSharpEssentials.Maybe/Readme.MD b/CSharpEssentials.Maybe/Readme.MD index 788b542..24504e5 100644 --- a/CSharpEssentials.Maybe/Readme.MD +++ b/CSharpEssentials.Maybe/Readme.MD @@ -98,3 +98,42 @@ Maybe configs = configMaybes.Sequence(); | `Sequence()` | `Maybe` | `None` if any element is `None` | | `Traverse(selector)` | `Maybe` | Applies selector then sequences | | `Partition()` | `(T[] Values, int NoneCount)` | Never returns `None` — always splits | + +### Exception-safe Creation + +```csharp +// Returns None if the factory throws +Maybe result = Maybe.FromTry(() => int.Parse(userInput)); +Maybe user = Maybe.FromTry(() => JsonSerializer.Deserialize(json)); +``` + +### TapNone — Side Effect on Absence + +```csharp +Maybe maybe = Maybe.None; + +maybe.TapNone(() => logger.LogWarning("Value missing")); // called when None + +// Async +await maybe.TapNoneAsync(async () => await NotifyAsync("Missing")); + +// Via Task> / ValueTask> +await GetMaybeAsync().TapNoneAsync(() => fallback()); +``` + +### GetValueOrElse — Lazy Fallback + +```csharp +// Factory only called when None (unlike GetValueOrDefault which always evaluates) +int value = maybe.GetValueOrElse(() => ComputeExpensiveDefault()); +``` + +### OrElse — Lazy Maybe Chain + +```csharp +// Alias for Or(Func>) — returns self if Some, calls factory if None +Maybe config = GetFromCache().OrElse(() => GetFromDatabase()); + +// Async +Maybe config = await GetFromCache().OrElseAsync(() => Task.FromResult(GetFromDatabase())); +``` diff --git a/CSharpEssentials.Results/Readme.MD b/CSharpEssentials.Results/Readme.MD index d996032..7404a1f 100644 --- a/CSharpEssentials.Results/Readme.MD +++ b/CSharpEssentials.Results/Readme.MD @@ -146,3 +146,22 @@ Result pipeline = new[] { CheckStock(), ReserveItem(), CreateOrder() } | `Traverse(selector)` | Map then sequence | `Result` | | `Partition()` | Split | `(T[] Successes, Error[] Errors)` | | `FirstFailureOrSuccesses()` | Short-circuit | `Result` or `Result` | + +### TapIf — Conditional Side Effects + +```csharp +Result result = Result.Success(42); + +// Predicate-based tap — action fires only when Success AND predicate is true +result.TapIf(v => v > 10, v => logger.Log($"Large value: {v}")); + +// Bool condition +result.TapIf(featureEnabled, v => Audit(v)); + +// Async variants +await result.TapIfAsync(v => v > 10, async v => await AuditAsync(v)); +await result.TapIfAsync(isEnabled, async v => await TrackAsync(v)); + +// Works on Task> and ValueTask> +await GetResultAsync().TapIfAsync(v => v > 0, v => Enqueue(v)); +``` diff --git a/CSharpEssentials.Rules/Readme.MD b/CSharpEssentials.Rules/Readme.MD index f974d41..32dc1c7 100644 --- a/CSharpEssentials.Rules/Readme.MD +++ b/CSharpEssentials.Rules/Readme.MD @@ -250,3 +250,28 @@ result.Match( ``` Rules return `Result` / `Result` — the same type used throughout CSharpEssentials — so rule outcomes compose directly with `Then`, `Match`, and other chaining operations. + +### FromPredicate / FromPredicateAsync — Inline Rules Without Classes + +```csharp +// Static error — reuse the same error for any failing context +IRule positiveRule = RuleEngine.FromPredicate( + x => x > 0, + Error.Validation("Value.Negative", "Must be positive")); + +// Error factory — error message can reference the failing value +IRule rangeRule = RuleEngine.FromPredicate( + x => x is >= 1 and <= 100, + x => Error.Validation("Value.OutOfRange", $"Value {x} must be between 1 and 100")); + +// Async predicate +IAsyncRule uniqueRule = RuleEngine.FromPredicateAsync( + async s => await _db.IsUniqueAsync(s), + s => Error.Conflict("Name.Taken", $"'{s}' is already taken")); + +// Evaluate normally — works with all composition methods +Result r = RuleEngine.Evaluate(positiveRule, 42); +Result composed = RuleEngine.Evaluate( + new IRuleBase[] { positiveRule, rangeRule }.And(), + value); +``` diff --git a/CSharpEssentials.Tests/Maybe/MaybeNewFeaturesTests.cs b/CSharpEssentials.Tests/Maybe/MaybeNewFeaturesTests.cs index ffda063..e43e15a 100644 --- a/CSharpEssentials.Tests/Maybe/MaybeNewFeaturesTests.cs +++ b/CSharpEssentials.Tests/Maybe/MaybeNewFeaturesTests.cs @@ -71,6 +71,94 @@ public async Task TapNoneAsync_Should_ExecuteAction_When_HasNoValue() called.Should().BeTrue(); } + [Fact] + public async Task TapNoneAsync_Task_SyncAction_Should_ExecuteAction_When_HasNoValue() + { + bool called = false; + Task> maybeTask = Task.FromResult(Maybe.None); + + await maybeTask.TapNoneAsync(() => called = true); + + called.Should().BeTrue(); + } + + [Fact] + public async Task TapNoneAsync_Task_SyncAction_Should_NotExecuteAction_When_HasValue() + { + bool called = false; + Task> maybeTask = Task.FromResult>(42); + + await maybeTask.TapNoneAsync(() => called = true); + + called.Should().BeFalse(); + } + + [Fact] + public async Task TapNoneAsync_Task_AsyncAction_Should_ExecuteAction_When_HasNoValue() + { + bool called = false; + Task> maybeTask = Task.FromResult(Maybe.None); + + await maybeTask.TapNoneAsync(async () => { await Task.Yield(); called = true; }); + + called.Should().BeTrue(); + } + + [Fact] + public async Task TapNoneAsync_ValueTask_SyncAction_Should_ExecuteAction_When_HasNoValue() + { + bool called = false; + ValueTask> maybeTask = new(Maybe.None); + + await maybeTask.TapNoneAsync(() => called = true); + + called.Should().BeTrue(); + } + + [Fact] + public async Task TapNoneAsync_ValueTask_SyncAction_Should_NotExecuteAction_When_HasValue() + { + bool called = false; + ValueTask> maybeTask = new((Maybe)42); + + await maybeTask.TapNoneAsync(() => called = true); + + called.Should().BeFalse(); + } + + [Fact] + public async Task TapNoneAsync_ValueTask_AsyncAction_Should_ExecuteAction_When_HasNoValue() + { + bool called = false; + ValueTask> maybeTask = new(Maybe.None); + + await maybeTask.TapNoneAsync(async () => { await Task.Yield(); called = true; }); + + called.Should().BeTrue(); + } + + [Fact] + public async Task TapNoneAsync_ValueTask_AsyncAction_Should_NotExecuteAction_When_HasValue() + { + bool called = false; + ValueTask> maybeTask = new((Maybe)42); + + await maybeTask.TapNoneAsync(async () => { await Task.Yield(); called = true; }); + + called.Should().BeFalse(); + } + + [Fact] + public async Task TapNoneAsync_Task_AsyncAction_Should_NotExecuteAction_When_HasValue() + { + bool called = false; + Task> maybeTask = Task.FromResult>(42); + + await maybeTask.TapNoneAsync(async () => { await Task.Yield(); called = true; }); + + called.Should().BeFalse(); + } + // GetValueOrElse [Fact] public void GetValueOrElse_Should_ReturnValue_When_HasValue() diff --git a/CSharpEssentials.Tests/Rules/RuleEngineFromPredicateTests.cs b/CSharpEssentials.Tests/Rules/RuleEngineFromPredicateTests.cs index b77f72b..856310a 100644 --- a/CSharpEssentials.Tests/Rules/RuleEngineFromPredicateTests.cs +++ b/CSharpEssentials.Tests/Rules/RuleEngineFromPredicateTests.cs @@ -57,6 +57,17 @@ public void FromPredicateAsync_Should_ReturnFailure_When_PredicateFalse() result.IsFailure.Should().BeTrue(); } + [Fact] + public void FromPredicateAsync_WithFactory_Should_UseContextInError() + { + IAsyncRule rule = RuleEngine.FromPredicateAsync( + async x => { await Task.Yield(); return x > 0; }, + x => Error.Failure("Negative.Value", $"Value {x} is not positive")); + Result result = RuleEngine.Evaluate(rule, -3); + result.IsFailure.Should().BeTrue(); + result.FirstError.Description.Should().Contain("-3"); + } + [Fact] public void FromPredicate_Should_WorkWithComplexContext() { diff --git a/CSharpEssentials.Tests/These/TheseJsonTests.cs b/CSharpEssentials.Tests/These/TheseJsonTests.cs new file mode 100644 index 0000000..899cf96 --- /dev/null +++ b/CSharpEssentials.Tests/These/TheseJsonTests.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CSharpEssentials.These; +using FluentAssertions; + +namespace CSharpEssentials.Tests.These; + +public class TheseJsonTests +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void Serialize_Should_ProduceCorrectJson_When_IsLeft() + { + These these = These.Left("error"); + + string json = JsonSerializer.Serialize(these, Options); + + json.Should().Contain("\"isLeft\":true"); + json.Should().Contain("\"isRight\":false"); + json.Should().Contain("\"left\":\"error\""); + json.Should().NotContain("\"right\""); + } + + [Fact] + public void Serialize_Should_ProduceCorrectJson_When_IsRight() + { + These these = These.Right(42); + + string json = JsonSerializer.Serialize(these, Options); + + json.Should().Contain("\"isLeft\":false"); + json.Should().Contain("\"isRight\":true"); + json.Should().Contain("\"right\":42"); + json.Should().NotContain("\"left\""); + } + + [Fact] + public void Serialize_Should_ProduceCorrectJson_When_IsBoth() + { + These these = These.Both("warning", 42); + + string json = JsonSerializer.Serialize(these, Options); + + json.Should().Contain("\"isLeft\":true"); + json.Should().Contain("\"isRight\":true"); + json.Should().Contain("\"left\":\"warning\""); + json.Should().Contain("\"right\":42"); + } + + [Fact] + public void Deserialize_Should_RoundTrip_When_IsLeft() + { + These original = These.Left("error"); + string json = JsonSerializer.Serialize(original, Options); + + These result = JsonSerializer.Deserialize>(json, Options); + + result.IsLeft.Should().BeTrue(); + result.GetLeft().Value.Should().Be("error"); + } + + [Fact] + public void Deserialize_Should_RoundTrip_When_IsRight() + { + These original = These.Right(42); + string json = JsonSerializer.Serialize(original, Options); + + These result = JsonSerializer.Deserialize>(json, Options); + + result.IsRight.Should().BeTrue(); + result.GetRight().Value.Should().Be(42); + } + + [Fact] + public void Deserialize_Should_RoundTrip_When_IsBoth() + { + These original = These.Both("warning", 42); + string json = JsonSerializer.Serialize(original, Options); + + These result = JsonSerializer.Deserialize>(json, Options); + + result.IsBoth.Should().BeTrue(); + result.GetLeft().Value.Should().Be("warning"); + result.GetRight().Value.Should().Be(42); + } + + [Fact] + public void IsBoth_Discriminator_Should_NotAppearInJson() + { + These these = These.Both("w", 1); + + string json = JsonSerializer.Serialize(these, Options); + + // IsBoth is [JsonIgnore] — not serialized + json.Should().NotContain("\"isBoth\""); + // Raw backing booleans (HasLeft/HasRight) ARE in JSON as isLeft/isRight + json.Should().Contain("\"isLeft\":true"); + json.Should().Contain("\"isRight\":true"); + } +} diff --git a/CSharpEssentials.Tests/These/TheseTests.cs b/CSharpEssentials.Tests/These/TheseTests.cs index bc85f95..2891739 100644 --- a/CSharpEssentials.Tests/These/TheseTests.cs +++ b/CSharpEssentials.Tests/These/TheseTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CSharpEssentials.Errors; using CSharpEssentials.ResultPattern; using CSharpEssentials.These; @@ -65,7 +66,7 @@ public void Map_Should_PassThrough_When_IsLeft() [Fact] public void MapLeft_Should_TransformError_When_IsLeft() { - var these = These.Left("err").MapLeft(e => e.ToUpper()); + var these = These.Left("err").MapLeft(e => e.ToUpper(CultureInfo.InvariantCulture)); these.GetLeft().Value.Should().Be("ERR"); } @@ -73,7 +74,7 @@ public void MapLeft_Should_TransformError_When_IsLeft() [Fact] public void MapLeft_Should_TransformError_When_IsBoth() { - var these = These.Both("err", 5).MapLeft(e => e.ToUpper()); + var these = These.Both("err", 5).MapLeft(e => e.ToUpper(CultureInfo.InvariantCulture)); these.IsBoth.Should().BeTrue(); these.GetLeft().Value.Should().Be("ERR"); @@ -82,7 +83,7 @@ public void MapLeft_Should_TransformError_When_IsBoth() [Fact] public void MapLeft_Should_PassThrough_When_IsRight() { - var these = These.Right(1).MapLeft(e => e.ToUpper()); + var these = These.Right(1).MapLeft(e => e.ToUpper(CultureInfo.InvariantCulture)); these.IsRight.Should().BeTrue(); } @@ -91,7 +92,7 @@ public void MapLeft_Should_PassThrough_When_IsRight() public void FlatMap_Should_Chain_When_IsRight() { var these = These.Right(5) - .FlatMap(x => These.Right(x.ToString())); + .FlatMap(x => These.Right(x.ToString(CultureInfo.InvariantCulture))); these.GetRight().Value.Should().Be("5"); } @@ -100,7 +101,7 @@ public void FlatMap_Should_Chain_When_IsRight() public void FlatMap_Should_Chain_When_IsBoth() { var these = These.Both("w", 5) - .FlatMap(x => These.Right(x.ToString())); + .FlatMap(x => These.Right(x.ToString(CultureInfo.InvariantCulture))); these.IsRight.Should().BeTrue(); these.GetRight().Value.Should().Be("5"); @@ -110,7 +111,7 @@ public void FlatMap_Should_Chain_When_IsBoth() public void FlatMap_Should_ReturnLeft_When_IsLeft() { var these = These.Left("err") - .FlatMap(x => These.Right(x.ToString())); + .FlatMap(x => These.Right(x.ToString(CultureInfo.InvariantCulture))); these.IsLeft.Should().BeTrue(); } @@ -286,6 +287,16 @@ public void ToResult_Should_ReturnFailure_When_IsLeft() result.IsFailure.Should().BeTrue(); } + [Fact] + public void ToResult_Should_ReturnFailure_When_IsBoth() + { + These these = These.Both(Error.Failure("E", "warn"), 42); + + Result result = these.ToResult(); + + result.IsFailure.Should().BeTrue(); + } + [Fact] public void ToResultLenient_Should_ReturnSuccess_When_IsBoth() { @@ -297,6 +308,39 @@ public void ToResultLenient_Should_ReturnSuccess_When_IsBoth() result.Value.Should().Be(42); } + [Fact] + public void ToResultLenient_Should_ReturnSuccess_When_IsRight() + { + These these = These.Right(42); + + Result result = these.ToResultLenient(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + [Fact] + public void ToResultLenient_Should_ReturnFailure_When_IsLeft() + { + These these = These.Left(Error.Failure("E", "fail")); + + Result result = these.ToResultLenient(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Partition_Should_HandleEmptyCollection() + { + var items = Array.Empty>(); + + var (lefts, rights, boths) = items.Partition(); + + lefts.Should().BeEmpty(); + rights.Should().BeEmpty(); + boths.Should().BeEmpty(); + } + [Fact] public void Partition_Should_SeparateIntoThreeGroups() { diff --git a/CSharpEssentials.Tests/Time/FakeDateTimeProviderTests.cs b/CSharpEssentials.Tests/Time/FakeDateTimeProviderTests.cs index b5ff313..3da7ab9 100644 --- a/CSharpEssentials.Tests/Time/FakeDateTimeProviderTests.cs +++ b/CSharpEssentials.Tests/Time/FakeDateTimeProviderTests.cs @@ -54,4 +54,20 @@ public void TimeZone_Should_BeUtc() provider.TimeZone.Should().Be(TimeZoneInfo.Utc); provider.TimeZoneUtc.Should().Be(TimeZoneInfo.Utc); } + +#if NET6_0_OR_GREATER + [Fact] + public void UtcNowDate_Should_ReturnDateOnly_When_ProviderInitialized() + { + var provider = new FakeDateTimeProvider(FixedTime); + provider.UtcNowDate.Should().Be(DateOnly.FromDateTime(FixedTime.UtcDateTime)); + } + + [Fact] + public void UtcNowTime_Should_ReturnTimeOnly_When_ProviderInitialized() + { + var provider = new FakeDateTimeProvider(FixedTime); + provider.UtcNowTime.Should().Be(TimeOnly.FromDateTime(FixedTime.UtcDateTime)); + } +#endif } diff --git a/CSharpEssentials.These/Readme.MD b/CSharpEssentials.These/Readme.MD index 2e926d4..3e1ef62 100644 --- a/CSharpEssentials.These/Readme.MD +++ b/CSharpEssentials.These/Readme.MD @@ -1 +1,148 @@ # CSharpEssentials.These + +`These` is a three-state discriminated union — `Left` (error only), `Right` (value only), or `Both` (error and value together). Unlike `Result`, it does not treat the presence of an error as an automatic failure. Use it when partial success carries a meaningful value alongside a warning or non-fatal error. + +## Features + +- **Three-state union** — `Left`, `Right`, `Both` with explicit state flags. +- **Functional API** — `Map`, `MapLeft`, `FlatMap`, `Tap`, `TapLeft`, `Match`. +- **Maybe bridges** — `GetRight()` and `GetLeft()` return `Maybe` without throwing. +- **Result bridges** — Convert to/from `Result` in strict or lenient mode. +- **Collection extensions** — Partition a sequence into its three states. + +## Installation + +```bash +dotnet add package CSharpEssentials.These +``` + +## Usage + +### Creating These + +```csharp +using CSharpEssentials.These; + +// Error only — no value produced +These left = These.Left("something went wrong"); + +// Value only — clean success +These right = These.Right(42); + +// Both — value produced but a warning accompanies it +These both = These.Both("partial failure", 42); +``` + +### State Flags + +```csharp +these.IsLeft // true when Left only +these.IsRight // true when Right only +these.IsBoth // true when Both +``` + +### Map — Transform the Value + +`Map` applies a function to the value. Left passes through unchanged; Both transforms the value while keeping the error. + +```csharp +These result = These.Right(10) + .Map(n => n * 2); // Right(20) + +These both = These.Both("warn", 10) + .Map(n => n * 2); // Both("warn", 20) +``` + +### MapLeft — Transform the Error + +```csharp +These result = These.Left("err") + .MapLeft(e => e.Length); // Left(3) +``` + +### FlatMap — Chain These + +`FlatMap` sequences operations that return `These`. When chaining from `Both`, the original error is discarded; the new result determines state. + +```csharp +These result = These.Right(5) + .FlatMap(n => n > 0 + ? These.Right(n * 10) + : These.Left("non-positive")); +``` + +### Tap / TapLeft — Side Effects + +```csharp +these + .Tap(v => logger.LogInformation("Value: {V}", v)) // fires on Right or Both + .TapLeft(e => logger.LogWarning("Error: {E}", e)); // fires on Left or Both +``` + +### Match — Exhaustive Pattern Match + +```csharp +string message = these.Match( + onLeft: error => $"Failed: {error}", + onRight: value => $"OK: {value}", + onBoth: (error, val) => $"Partial: {val} (warning: {error})"); +``` + +### GetRight / GetLeft + +Both return `Maybe` — no exceptions on the wrong state. + +```csharp +Maybe value = these.GetRight(); // Some(42) or None +Maybe error = these.GetLeft(); // Some("msg") or None +``` + +## Result Bridge + +```csharp +using CSharpEssentials.These; + +// From Result +Result result = GetSomeResult(); +These these = TheseExtensions.FromResult(result); + +// To Result — strict: Both is treated as failure (Left wins) +Result strict = these.ToResult(); + +// To Result — lenient: Both is treated as success (value wins) +Result lenient = these.ToResultLenient(); +``` + +| Conversion | Left | Right | Both | +|------------|------|-------|------| +| `ToResult()` | Failure | Success | Failure | +| `ToResultLenient()` | Failure | Success | Success | + +## Collection Extensions + +Partition a sequence into its three buckets in one pass. + +```csharp +IEnumerable> items = GetItems(); + +var (lefts, rights, boths) = items.Partition(); + +// lefts : IReadOnlyList +// rights : IReadOnlyList +// boths : IReadOnlyList<(string, int)> + +foreach (string error in lefts) + Console.WriteLine($"Error: {error}"); + +foreach ((string warning, int value) in boths) + Console.WriteLine($"Value {value} with warning: {warning}"); +``` + +## When to Use These vs Result + +| Scenario | Use | +|----------|-----| +| Operation either succeeds or fails | `Result` | +| Partial success with a non-fatal warning | `These` | +| Accumulating errors while still producing a value | `These` | +| Enrichment pipelines (value + audit log) | `These` | diff --git a/CSharpEssentials.These/These.cs b/CSharpEssentials.These/These.cs index 1ec2048..2dc56c4 100644 --- a/CSharpEssentials.These/These.cs +++ b/CSharpEssentials.These/These.cs @@ -1,25 +1,45 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; using CSharpEssentials.Maybe; namespace CSharpEssentials.These; public readonly record struct These { - private readonly bool _isLeft; - private readonly bool _isRight; - private readonly TError? _leftValue; - private readonly TValue? _rightValue; - - private These(bool isLeft, bool isRight, TError? left, TValue? right) + [JsonConstructor] + [SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Used by System.Text.Json")] + private These(bool hasLeft, bool hasRight, TError? leftOrDefault, TValue? rightOrDefault) { - _isLeft = isLeft; - _isRight = isRight; - _leftValue = left; - _rightValue = right; + HasLeft = hasLeft; + HasRight = hasRight; + LeftOrDefault = leftOrDefault; + RightOrDefault = rightOrDefault; } - public bool IsLeft => _isLeft && !_isRight; - public bool IsRight => !_isLeft && _isRight; - public bool IsBoth => _isLeft && _isRight; + // Raw backing state — serialized for JSON round-trip support + // HasLeft is true for both Left and Both states + [JsonPropertyName("isLeft")] + public bool HasLeft { get; } + + // HasRight is true for both Right and Both states + [JsonPropertyName("isRight")] + public bool HasRight { get; } + + [JsonPropertyName("left")] + public TError? LeftOrDefault { get; } + + [JsonPropertyName("right")] + public TValue? RightOrDefault { get; } + + // Computed discriminators — excluded from JSON, derived from HasLeft/HasRight + [JsonIgnore] + public bool IsLeft => HasLeft && !HasRight; + + [JsonIgnore] + public bool IsRight => !HasLeft && HasRight; + + [JsonIgnore] + public bool IsBoth => HasLeft && HasRight; public static These Left(TError error) => new(true, false, error, default); public static These Right(TValue value) => new(false, true, default, value); @@ -28,54 +48,57 @@ private These(bool isLeft, bool isRight, TError? left, TValue? right) public These Map(Func mapper) { if (IsRight) - return These.Right(mapper(_rightValue!)); + return These.Right(mapper(RightOrDefault!)); if (IsBoth) - return These.Both(_leftValue!, mapper(_rightValue!)); - return These.Left(_leftValue!); + return These.Both(LeftOrDefault!, mapper(RightOrDefault!)); + return These.Left(LeftOrDefault!); } public These MapLeft(Func mapper) { if (IsLeft) - return These.Left(mapper(_leftValue!)); + return These.Left(mapper(LeftOrDefault!)); if (IsBoth) - return These.Both(mapper(_leftValue!), _rightValue!); - return These.Right(_rightValue!); + return These.Both(mapper(LeftOrDefault!), RightOrDefault!); + return These.Right(RightOrDefault!); } public These FlatMap(Func> mapper) { if (IsRight || IsBoth) - return mapper(_rightValue!); - return These.Left(_leftValue!); + return mapper(RightOrDefault!); + return These.Left(LeftOrDefault!); } public These Tap(Action action) { if (IsRight || IsBoth) - action(_rightValue!); + action(RightOrDefault!); return this; } public These TapLeft(Action action) { if (IsLeft || IsBoth) - action(_leftValue!); + action(LeftOrDefault!); return this; } - public TResult Match(Func onLeft, Func onRight, Func onBoth) + public TResult Match( + Func onLeft, + Func onRight, + Func onBoth) { if (IsLeft) - return onLeft(_leftValue!); + return onLeft(LeftOrDefault!); if (IsRight) - return onRight(_rightValue!); - return onBoth(_leftValue!, _rightValue!); + return onRight(RightOrDefault!); + return onBoth(LeftOrDefault!, RightOrDefault!); } public Maybe GetRight() - => (IsRight || IsBoth) ? Maybe.From(_rightValue) : Maybe.None; + => (IsRight || IsBoth) ? Maybe.From(RightOrDefault) : Maybe.None; public Maybe GetLeft() - => (IsLeft || IsBoth) ? Maybe.From(_leftValue) : Maybe.None; + => (IsLeft || IsBoth) ? Maybe.From(LeftOrDefault) : Maybe.None; } diff --git a/CSharpEssentials.These/TheseExtensions.cs b/CSharpEssentials.These/TheseExtensions.cs index 7a5cdd7..aef28a0 100644 --- a/CSharpEssentials.These/TheseExtensions.cs +++ b/CSharpEssentials.These/TheseExtensions.cs @@ -12,14 +12,14 @@ public static These FromResult(Result result) public static Result ToResult(this These these) { - if (these.IsLeft) + if (these.IsLeft || these.IsBoth) return Result.Failure(these.GetLeft().Value); return these.GetRight().Value; } public static Result ToResultLenient(this These these) { - if (these.IsLeft && !these.IsBoth) + if (these.IsLeft) return Result.Failure(these.GetLeft().Value); return these.GetRight().Value; } diff --git a/CSharpEssentials.Time/Readme.MD b/CSharpEssentials.Time/Readme.MD index 6b44b38..d013359 100644 --- a/CSharpEssentials.Time/Readme.MD +++ b/CSharpEssentials.Time/Readme.MD @@ -41,16 +41,29 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); ``` -### Testing with FakeTimeProvider +### Test with FakeDateTimeProvider + +`CSharpEssentials.Time` ships a built-in `FakeDateTimeProvider` — no extra package needed. ```csharp -using Microsoft.Extensions.Time.Testing; +using CSharpEssentials.Time; + +var fixed = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero); +var fake = new FakeDateTimeProvider(fixed); + +var svc = new OrderService(fake); +var order = svc.Create(cart); + +// Advance the clock — no Thread.Sleep needed +fake.Advance(TimeSpan.FromHours(2)); +fake.UtcNow.Should().Be(fixed.AddHours(2)); -FakeTimeProvider fakeTime = new(); -fakeTime.SetUtcNow(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); +// Set to a specific time +fake.SetTime(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); -IDateTimeProvider provider = new DateTimeProvider(fakeTime); -provider.UtcNow.Year.Should().Be(2025); +// NET6+ only +fake.UtcNowDate.Should().Be(new DateOnly(2026, 1, 1)); +fake.UtcNowTime.Should().Be(new TimeOnly(0, 0, 0)); ``` ### Extensions diff --git a/PARITY-PLAN.md b/PARITY-PLAN.md deleted file mode 100644 index 95c1352..0000000 --- a/PARITY-PLAN.md +++ /dev/null @@ -1,518 +0,0 @@ -# C# ↔ TypeScript Parity Plan - -> **Amaç:** `CSharpEssentials` ile `tsentials` arasındaki tutarlılığı korumak için bu dosya, -> C# tarafına eklenmesi gereken her özelliği açıklar. Her madde için TypeScript tarafındaki -> referans dosya ve implementasyon notu verilmiştir. -> -> **Kardeş proje:** `/Users/recepsen/Documents/projects/recep/TypeScriptEssentials` -> **Kardeş plan:** `/Users/recepsen/Documents/projects/recep/TypeScriptEssentials/PARITY-PLAN.md` - ---- - -## Durum Göstergesi - -| Sembol | Anlam | -|--------|-------| -| `[ ]` | Henüz yapılmadı | -| `[x]` | Tamamlandı | -| `[-]` | Bilinçli olarak atlandı (dil farkı) | - ---- - -## 1. `CSharpEssentials.These` — Yeni Paket (Öncelik: YÜKSEK) ✅ - -**Neden:** `These` hem hata hem değer taşıyabilen üçüncü bir sonuç tipidir. -`Left(error)`, `Right(value)`, `Both(error, value)` — kısmi başarı senaryolarında kritik. -Batch validation, uyarı sistemi gibi kullanım alanları var. - -### TypeScript Referans -``` -src/these/index.ts -``` - -### C# Tip Tasarımı -```csharp -// Yeni proje: CSharpEssentials.These/ -public readonly record struct These -{ - private readonly bool _isLeft; - private readonly bool _isRight; - private readonly E? _leftValue; - private readonly A? _rightValue; - - private These(bool isLeft, bool isRight, E? left, A? right) - { - _isLeft = isLeft; - _isRight = isRight; - _leftValue = left; - _rightValue = right; - } - - public static These Left(E error) => new(true, false, error, default); - public static These Right(A value) => new(false, true, default, value); - public static These Both(E error, A value) => new(true, true, error, value); - - public bool IsLeft => _isLeft && !_isRight; - public bool IsRight => !_isLeft && _isRight; - public bool IsBoth => _isLeft && _isRight; -} -``` - -### Eklenecek Metodlar (TS ile 1:1) - -**Factories:** -- [x] `These.Left(E)` -- [x] `These.Right(A)` -- [x] `These.Both(E, A)` -- [x] `These.FromResult(Result)` — success → Right, failure → Left(firstError) - -**Guards:** -- [x] `IsLeft`, `IsRight`, `IsBoth` properties - -**Pipeline:** -- [x] `Map(Func)` — sadece Right/Both value'yu dönüştürür -- [x] `MapLeft(Func)` — sadece Left/Both error'ı dönüştürür -- [x] `FlatMap(Func>)` -- [x] `Tap(Action)` — Right/Both için side effect -- [x] `TapLeft(Action)` — Left/Both için side effect - -**Extraction:** -- [x] `Match(Func onLeft, Func onRight, Func onBoth)` -- [x] `GetRight()` / `GetLeft()` — `Maybe` döner - -**Conversions:** -- [x] `ToResult()` — Both ve Right → success, Left → failure -- [x] `ToResultLenient()` — Both → success (hata görmezden gelinir), Left → failure - -**Collections:** -- [x] `TheseCollectionExtensions.Partition(IEnumerable>)` → `(IReadOnlyList lefts, IReadOnlyList rights, IReadOnlyList<(E,A)> boths)` - -### Dosya Yapısı -``` -CSharpEssentials.These/ - These.cs - TheseExtensions.cs - TheseCollectionExtensions.cs - CSharpEssentials.These.csproj -``` - -### TS Referans Detayı -``` -src/these/index.ts ← tüm implementasyon referansı burada -``` - ---- - -## 2. `Result.MatchFirst` / `Result.MatchLast` (Öncelik: ORTA) ✅ - -**Neden:** TS tarafında `matchFirst` ve `matchLast` var; C# tarafında yalnızca `Match` var -ve tüm error listesini alıyor. İlk ya da son hata ile çalışan varyant eksik. - -### TypeScript Referans -``` -src/result/result.ts ← matchFirst, matchLast implementasyonu -``` - -### Hedef Dosya -``` -CSharpEssentials.Results/Modules/ResultT.Match.cs ← mevcut Match'in yanına ekle -``` - -### C# İmzalar -```csharp -// ResultT partial class genişletmesi -public TResult MatchFirst( - Func onSuccess, - Func onFirstFailure) - => IsSuccess ? onSuccess(Value) : onFirstFailure(FirstError); - -public TResult MatchLast( - Func onSuccess, - Func onLastFailure) - => IsSuccess ? onSuccess(Value) : onLastFailure(Errors[^1]); -``` - ---- - -## 3. `Result.SwitchFirst` / `Result.SwitchLast` (Öncelik: ORTA) ✅ - -**Neden:** `MatchFirst`/`MatchLast`'ın void varyantları. TS'de `switchFirst`, `switchLast` mevcut. - -### TypeScript Referans -``` -src/result/result.ts ← switchFirst, switchLast -``` - -### Hedef Dosya -``` -CSharpEssentials.Results/Modules/ResultT.Switch.cs ← mevcut Switch'in yanına ekle -``` - -### C# İmzalar -```csharp -public void SwitchFirst(Action onSuccess, Action onFirstFailure) -{ - if (IsSuccess) onSuccess(Value); - else onFirstFailure(FirstError); -} - -public void SwitchLast(Action onSuccess, Action onLastFailure) -{ - if (IsSuccess) onSuccess(Value); - else onLastFailure(Errors[^1]); -} -``` - ---- - -## 4. `Result.TapIf` / `Result.TapErrorIf` (Öncelik: ORTA) ✅ - -**Neden:** TS'de `tapIf` ve `tapErrorIf` var; C#'da `Tap` ve `TapError` var ama -koşullu varyantları yok. `BindIf` var ancak side-effect odaklı versiyonu eksik. - -### TypeScript Referans -``` -src/result/result.ts ← tapIf, tapErrorIf -``` - -### Hedef Dosya -``` -CSharpEssentials.Results/Modules/ResultT.Tap.cs ← TapIf buraya -CSharpEssentials.Results/Modules/ResultT.TapError.cs ← TapErrorIf buraya -``` - -### C# İmzalar -```csharp -// ResultT partial (sync + async) -public Result TapIf(bool condition, Action action) - => IsSuccess && condition ? Tap(action) : this; - -public Result TapIf(Func predicate, Action action) - => IsSuccess && predicate(Value) ? Tap(action) : this; - -public Result TapErrorIf(bool condition, Action> action) - => IsFailure && condition ? TapError(action) : this; - -// Async varyantlar: -public Task> TapIfAsync(bool condition, Func action) -public Task> TapErrorIfAsync(bool condition, Func, Task> action) -``` - ---- - -## 5. `Result.CompensateFirst` / `Result.CompensateFirstAsync` (Öncelik: ORTA) ✅ - -**Neden:** TS'de `compensateFirst` var; sadece `errors[0]` üzerinden recover eder. -C#'da `Compensate` tüm error listesini alır; `CompensateFirst` sadece ilk hatayı verir. - -### TypeScript Referans -``` -src/result/result.ts ← compensateFirst, compensateFirstAsync -``` - -### Hedef Dosya -``` -CSharpEssentials.Results/Modules/ResultT.Compensate.cs ← Compensate'in yanına -``` - -### C# İmzalar -```csharp -public Result CompensateFirst(Func> fn) - => IsSuccess ? this : fn(FirstError); - -public Task> CompensateFirstAsync(Func>> fn) - => IsSuccess ? Task.FromResult(this) : fn(FirstError); -``` - ---- - -## 6. `Maybe.FromTry(Func)` (Öncelik: ORTA) ✅ - -**Neden:** Exception fırlatan bir ifadeyi `Maybe`'ye dönüştürme. TS'de `Maybe.fromTry` -mevcut; C#'da `Try` yok — exception → None yapan factory eksik. - -### TypeScript Referans -``` -src/maybe/maybe.ts ← Maybe.fromTry implementasyonu -``` - -### Hedef Dosya -``` -CSharpEssentials.Maybe/Maybe.cs ← Factory metodlar buraya (static section) -``` - -### C# İmza -```csharp -// Yeni static factory method -public static Maybe FromTry(Func factory) -{ - try { return From(factory()); } - catch { return None; } -} -``` - ---- - -## 7. `Maybe.TapNone(Action)` (Öncelik: ORTA) ✅ - -**Neden:** TS'de `Maybe.tapNone` var — None durumunda side effect için. -C#'da `Execute` / `Tap` yalnızca `HasValue` durumunda çalışıyor. - -### TypeScript Referans -``` -src/maybe/maybe.ts ← Maybe.tapNone -``` - -### Hedef Dosya -``` -CSharpEssentials.Maybe/Modules/Maybe.Tap.cs ← mevcut Tap dosyasına ekle -``` - -### C# İmzalar -```csharp -public Maybe TapNone(Action action) -{ - if (HasNoValue) action(); - return this; -} - -public async Task> TapNoneAsync(Func action) -{ - if (HasNoValue) await action(); - return this; -} -``` - ---- - -## 8. `Maybe.GetOrElse(Func)` — Lazy Fallback (Öncelik: ORTA) ✅ - -**Neden:** TS'de `Maybe.getOrElse(m, fn)` mevcut — None durumunda factory çağırır. -C#'da `GetValueOrDefault(defaultValue)` var ama lazy (factory) versiyonu yok. - -### TypeScript Referans -``` -src/maybe/maybe.ts ← Maybe.getOrElse -``` - -### Hedef Dosya -``` -CSharpEssentials.Maybe/Modules/GetValueOrDefault.cs ← mevcut dosyaya factory overload -``` - -### C# İmza -```csharp -// Overload: lazy factory yerine sabit değer zaten var -public T GetValueOrElse(Func factory) - => HasValue ? Value : factory(); -``` - ---- - -## 9. `Maybe.OrElse(Func>)` — Lazy Fallback Chain (Öncelik: ORTA) ✅ - -**Neden:** TS'de `Maybe.orElse(m, fn)` var — None durumunda lazy Maybe döner. -C#'da `Or(Maybe)` var ama eager (önceden hesaplanmış); lazy varyant yok. - -### TypeScript Referans -``` -src/maybe/maybe.ts ← Maybe.orElse -``` - -### Hedef Dosya -``` -CSharpEssentials.Maybe/Modules/Or.cs ← mevcut Or'un yanına -``` - -### C# İmza -```csharp -public Maybe OrElse(Func> factory) - => HasValue ? this : factory(); - -public Task> OrElseAsync(Func>> factory) - => HasValue ? Task.FromResult(this) : factory(); -``` - ---- - -## 10. `RuleEngine.FromPredicate` / `FromPredicateAsync` (Öncelik: ORTA) ✅ - -**Neden:** TS'de `RuleEngine.fromPredicate(pred, error)` var — predicate'ten direkt kural oluşturur. -C#'da bu factory yok; kullanıcı `IRule` implement etmek zorunda. - -### TypeScript Referans -``` -src/rules/rule-engine.ts ← RuleEngine.fromPredicate, fromPredicateAsync -``` - -### Hedef Dosya -``` -CSharpEssentials.Rules/Engines/Func.cs ← mevcut func-tabanlı kural factory'si -``` - -### C# İmzalar -```csharp -// RuleEngine static class genişletmesi -public static IRule FromPredicate( - Func predicate, - Error error) - => new DelegateRule(ctx => predicate(ctx) - ? Result.Success() - : Result.Failure(error)); - -public static IRule FromPredicate( - Func predicate, - Func errorFactory) - => new DelegateRule(ctx => predicate(ctx) - ? Result.Success() - : Result.Failure(errorFactory(ctx))); - -public static IAsyncRule FromPredicateAsync( - Func> predicate, - Error error); -``` - -**`DelegateRule` yardımcı sınıfı:** -```csharp -// CSharpEssentials.Rules/Engines/ altına yeni dosya -internal sealed class DelegateRule(Func fn) : IRule -{ - public Result Evaluate(T context) => fn(context); -} -``` - ---- - -## 11. `RuleEngine` Typed Varyantları (Öncelik: ORTA) - -**Neden:** TS'de `linearTyped`, `andTyped`, `orTyped` — farklı input/output tipler alan -typed rule combinator'ları mevcut. C# tarafında `RuleEngine` var -ama explicit typed factory yok. - -### TypeScript Referans -``` -src/rules/rule-engine.ts ← linearTyped, andTyped, orTyped, TypedRule -``` - -### Hedef Dosya -``` -CSharpEssentials.Rules/Engines/RuleEngineTResult.cs ← mevcut typed engine -``` - -### C# İmzalar (taslak) -```csharp -// Generic typed combinators -public static IRule LinearTyped( - params IRule[] rules); - -public static IRule AndTyped( - params IRule[] rules); - -public static IRule OrTyped( - params IRule[] rules); -``` - ---- - -## 12. `FakeDateTimeProvider` — Test Double (Öncelik: DÜŞÜK) ✅ - -**Neden:** TS'de `createFakeDateTimeProvider(fixedTime)` ile `advance(ms)` ve `setTime(date)` -metodları olan test double mevcut. C# test projelerinde büyük kolaylık sağlar. - -### TypeScript Referans -``` -src/time/date-time-provider.ts ← createFakeDateTimeProvider implementasyonu -``` - -### Hedef Dosya -``` -CSharpEssentials.Time/ ← mevcut proje içine yeni dosya - FakeDateTimeProvider.cs -``` - -### C# İmza -```csharp -public sealed class FakeDateTimeProvider(DateTimeOffset fixedTime) : IDateTimeProvider -{ - private DateTimeOffset _current = fixedTime; - - public DateTimeOffset UtcNow => _current; - public DateOnly UtcNowDate => DateOnly.FromDateTime(_current.UtcDateTime); - public TimeOnly UtcNowTime => TimeOnly.FromDateTime(_current.UtcDateTime); - - public void Advance(TimeSpan duration) => _current = _current.Add(duration); - public void SetTime(DateTimeOffset time) => _current = time; -} -``` - ---- - -## 13. `pipe` / `flow` Function Combinators (Öncelik: DÜŞÜK) - -**Neden:** TS'de `pipe(value, fn1, fn2, ...)` ve `flow(fn1, fn2, ...)` mevcut. -C# LINQ + extension method'larla benzer ifade gücü var ama explicit pipe/flow -daha FP-uyumlu kod yazımını kolaylaştırır. - -### TypeScript Referans -``` -src/function/index.ts ← pipe, flow, identity, constant, flip implementasyonu -``` - -### C# Değerlendirmesi - -C# 13+ `extension members` feature'ı ile şu şekilde implement edilebilir: - -```csharp -// CSharpEssentials.Core/Extensions/FunctionExtensions.cs -public static class FunctionExtensions -{ - // pipe: değer alıp dizi fonksiyon uyguluyor - public static TResult Pipe(this T value, Func fn) - => fn(value); - - public static TResult Pipe( - this T value, - Func fn1, - Func fn2) - => fn2(fn1(value)); - - // ... arity 3-8 overload'lar - - // identity - public static T Identity(T value) => value; - - // constant - public static Func Constant(T value) => () => value; -} -``` - -**Not:** C#'da `pipe` için method chaining (extension method) daha idiomatik. -Bu nedenle `pipe` eklenmesi opsiyonel — kullanım ihtiyacı netleşirse ekle. - ---- - -## Tamamlanan İşlemler - -| # | Özellik | Paket | Tarih | -|---|---------|-------|-------| -| 1 | `CSharpEssentials.These` — yeni paket | `CSharpEssentials.These` | 2026-05-31 | -| 2 | `Result.MatchFirst` / `MatchLast` | `CSharpEssentials.Results` | önceki sürüm | -| 3 | `Result.SwitchFirst` / `SwitchLast` | `CSharpEssentials.Results` | önceki sürüm | -| 4 | `Result.TapIf(Func)` + async | `CSharpEssentials.Results` | 2026-05-31 | -| 5 | `Result.CompensateFirst` / `CompensateFirstAsync` | `CSharpEssentials.Results` | önceki sürüm | -| 6 | `Maybe.FromTry(Func)` | `CSharpEssentials.Maybe` | 2026-05-31 | -| 7 | `Maybe.TapNone` / `TapNoneAsync` | `CSharpEssentials.Maybe` | 2026-05-31 | -| 8 | `Maybe.GetValueOrElse(Func)` | `CSharpEssentials.Maybe` | 2026-05-31 | -| 9 | `Maybe.OrElse(Func>)` / `OrElseAsync` | `CSharpEssentials.Maybe` | 2026-05-31 | -| 10 | `RuleEngine.FromPredicate` / `FromPredicateAsync` | `CSharpEssentials.Rules` | 2026-05-31 | -| 12 | `FakeDateTimeProvider` | `CSharpEssentials.Time` | 2026-05-31 | - ---- - -## Notlar - -- Bu dosya hem insan hem AI agent tarafından okunmak üzere yazılmıştır. -- Her madde tamamlandığında `[ ]` → `[x]` olarak güncelle. -- Kardeş proje TS PARITY-PLAN ile çift yönlü senkronize tutulmalıdır. -- Her yeni C# paketi için `.csproj` dosyası oluştur ve ana `CSharpEssentials.sln`'e ekle. -- Test dosyaları `tests/` veya ilgili proje içi test klasörüne eklenmelidir. diff --git a/README.MD b/README.MD index fc55651..a84658f 100644 --- a/README.MD +++ b/README.MD @@ -6,7 +6,7 @@ [![Build](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml/badge.svg)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) -[![Tests](https://img.shields.io/badge/tests-2951%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) +[![Tests](https://img.shields.io/badge/tests-2973%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![Downloads](https://img.shields.io/nuget/dt/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/senrecep/CSharpEssentials/blob/main/LICENCE)