From faca4573abc650624660ab1e8cc9db35292d34c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 27 May 2026 20:31:12 +0300 Subject: [PATCH 1/2] test(results,collections,any): add missing coverage for async select, collection batch, and Any traverse - SelectAsync Task failure path (ResultT.Select.cs:99) - AnyCollectionExtensions T3 Traverse + T4 null selector guard - ResultCollectionExtensions Traverse failure, IResult Partition, FirstFailureOrSuccesses all-success paths Co-Authored-By: Claude Sonnet 4.6 --- .../Any/AnyCollectionExtensionsTests.cs | 28 +++++++++ .../Results/ResultCollectionBatchTests.cs | 58 +++++++++++++++++++ .../Results/ResultSelectTests.cs | 15 +++++ README.MD | 2 +- 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/CSharpEssentials.Tests/Any/AnyCollectionExtensionsTests.cs b/CSharpEssentials.Tests/Any/AnyCollectionExtensionsTests.cs index 058cef3..cbc8acb 100644 --- a/CSharpEssentials.Tests/Any/AnyCollectionExtensionsTests.cs +++ b/CSharpEssentials.Tests/Any/AnyCollectionExtensionsTests.cs @@ -176,6 +176,34 @@ public void CollectionExtensions_WithNullSource_ShouldThrowArgumentNullException action.Should().Throw().WithParameterName("source"); } + [Fact] + public void Traverse_T3_ShouldProjectAndPartition() + { + int[] source = [0, 1, 2, 3, 4]; + + var result = source.Traverse(value => value switch + { + 0 => value, + 1 => value.ToString(CultureInfo.InvariantCulture), + _ => true + }); + + result.First.Should().Equal(0); + result.Second.Should().Equal("1"); + result.Third.Should().Equal(true, true, true); + } + + [Fact] + public void Traverse_T4_WithNullSelector_ShouldThrowArgumentNullException() + { + int[] values = [1]; + Func> selector = null!; + + Action action = () => values.Traverse(selector); + + action.Should().Throw().WithParameterName("selector"); + } + [Fact] public void Traverse_WithNullSelector_ShouldThrowArgumentNullException() { diff --git a/CSharpEssentials.Tests/Results/ResultCollectionBatchTests.cs b/CSharpEssentials.Tests/Results/ResultCollectionBatchTests.cs index 61a8804..2dd5298 100644 --- a/CSharpEssentials.Tests/Results/ResultCollectionBatchTests.cs +++ b/CSharpEssentials.Tests/Results/ResultCollectionBatchTests.cs @@ -216,6 +216,64 @@ public void ResultCollectionExtensions_WithNullSource_ShouldThrowArgumentNullExc action.Should().Throw().WithParameterName("source"); } + [Fact] + public void Traverse_WithFailures_ShouldAggregateErrors() + { + int[] source = [1, 2, 3]; + + Result result = source.Traverse(value => + value == 2 + ? Result.Failure(Error.Validation("Traverse.Error", "Bad value")) + : $"item-{value}".ToResult()); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(x => x.Code == "Traverse.Error"); + } + + [Fact] + public void Partition_IResult_ShouldReturnSuccessesAndErrors() + { + IResult[] source = + [ + (Result)10, + (Result)Error.Validation("First.Error", "First"), + (Result)20, + (Result)Error.Validation("Second.Error", "Second") + ]; + + (int[] successes, Error[] errors) = source.Partition(); + + successes.Should().Equal(10, 20); + errors.Select(x => x.Code).Should().Equal("First.Error", "Second.Error"); + } + + [Fact] + public void FirstFailureOrSuccesses_AllSuccesses_ShouldReturnSuccessWithValues() + { + Result[] source = [1, 2, 3]; + + Result result = source.FirstFailureOrSuccesses(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Equal(1, 2, 3); + } + + [Fact] + public void FirstFailureOrSuccesses_IResult_AllSuccesses_ShouldReturnSuccessWithValues() + { + IResult[] source = + [ + (Result)1, + (Result)2, + (Result)3 + ]; + + Result result = source.FirstFailureOrSuccesses(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Equal(1, 2, 3); + } + [Fact] public void Traverse_WithNullSelector_ShouldThrowArgumentNullException() { diff --git a/CSharpEssentials.Tests/Results/ResultSelectTests.cs b/CSharpEssentials.Tests/Results/ResultSelectTests.cs index 213c7c4..213b926 100644 --- a/CSharpEssentials.Tests/Results/ResultSelectTests.cs +++ b/CSharpEssentials.Tests/Results/ResultSelectTests.cs @@ -164,6 +164,21 @@ public void ResultT_SelectMany_WithProjector_OriginalFailure_ShouldNotExecute() #endregion + #region SelectAsync (Task> overloads) + + [Fact] + public async Task SelectAsync_Task_OnFailure_ShouldReturnFailure() + { + Task> task = Task.FromResult(Result.Failure(TestError)); + + Result result = await task.SelectAsync(x => Task.FromResult(x.ToString(System.Globalization.CultureInfo.InvariantCulture))); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Should().Be(TestError); + } + + #endregion + #region Chaining (LINQ query syntax compatibility) [Fact] diff --git a/README.MD b/README.MD index 6db0720..fd24db4 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-2781%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) +[![Tests](https://img.shields.io/badge/tests-2788%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) ======= >>>>>>> pr-21 [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) From 2c92a1205afaf9c0ac857b345d10a96dbe08d046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Recep=20=C5=9Een?= Date: Wed, 27 May 2026 20:49:08 +0300 Subject: [PATCH 2/2] docs: update API reference, READMEs, skills, and script for new APIs - docs/API_REFERENCE.md: add collection extensions (Result, Maybe, Any) and railway validation bindings (ValidateWith/ValidateWithAsync) sections - CSharpEssentials.Results/Readme.MD: add collection extensions section - CSharpEssentials.Maybe/Readme.MD: add collection extensions section - CSharpEssentials.Any/Readme.MD: add collection extensions section - CSharpEssentials.Validation/Readme.MD: add railway integration section - .well-known/agent-skills/: add csharpessentials-validation skill, update index.json to 19 packages, remove duplicate .agents/skills/ - AGENTS.md: update to 19 packages, add git hooks setup line - CLAUDE.md: replace with thin reference pointing to AGENTS.md - scripts/analyze_example_coverage.py: remove hardcoded project path, add Validation to LIB_EXAMPLE_MAP, translate all comments to English Co-Authored-By: Claude Sonnet 4.6 --- .agents/skills/csharpessentials-any/SKILL.md | 84 ------- .../csharpessentials-aspnetcore/SKILL.md | 112 --------- .../skills/csharpessentials-clone/SKILL.md | 84 ------- .agents/skills/csharpessentials-core/SKILL.md | 75 ------ .../skills/csharpessentials-efcore/SKILL.md | 114 --------- .../skills/csharpessentials-entity/SKILL.md | 102 -------- .../skills/csharpessentials-enums/SKILL.md | 57 ----- .../skills/csharpessentials-errors/SKILL.md | 128 ---------- .../SKILL.md | 62 ----- .agents/skills/csharpessentials-http/SKILL.md | 78 ------ .agents/skills/csharpessentials-json/SKILL.md | 81 ------ .../skills/csharpessentials-logging/SKILL.md | 59 ----- .../skills/csharpessentials-maybe/SKILL.md | 76 ------ .../skills/csharpessentials-mediator/SKILL.md | 129 ---------- .agents/skills/csharpessentials-meta/SKILL.md | 115 --------- .../skills/csharpessentials-results/SKILL.md | 117 --------- .../skills/csharpessentials-rules/SKILL.md | 230 ------------------ .agents/skills/csharpessentials-time/SKILL.md | 110 --------- .../csharpessentials-validation/SKILL.md | 0 .well-known/agent-skills/index.json | 7 +- AGENTS.md | 3 +- CLAUDE.md | 44 +--- CSharpEssentials.Any/Readme.MD | 17 ++ CSharpEssentials.Maybe/Readme.MD | 19 ++ CSharpEssentials.Results/Readme.MD | 28 +++ CSharpEssentials.Validation/Readme.MD | 30 +++ docs/API_REFERENCE.md | 98 ++++++++ scripts/analyze_example_coverage.py | 102 ++++---- 28 files changed, 260 insertions(+), 1901 deletions(-) delete mode 100644 .agents/skills/csharpessentials-any/SKILL.md delete mode 100644 .agents/skills/csharpessentials-aspnetcore/SKILL.md delete mode 100644 .agents/skills/csharpessentials-clone/SKILL.md delete mode 100644 .agents/skills/csharpessentials-core/SKILL.md delete mode 100644 .agents/skills/csharpessentials-efcore/SKILL.md delete mode 100644 .agents/skills/csharpessentials-entity/SKILL.md delete mode 100644 .agents/skills/csharpessentials-enums/SKILL.md delete mode 100644 .agents/skills/csharpessentials-errors/SKILL.md delete mode 100644 .agents/skills/csharpessentials-gcpsecretmanager/SKILL.md delete mode 100644 .agents/skills/csharpessentials-http/SKILL.md delete mode 100644 .agents/skills/csharpessentials-json/SKILL.md delete mode 100644 .agents/skills/csharpessentials-logging/SKILL.md delete mode 100644 .agents/skills/csharpessentials-maybe/SKILL.md delete mode 100644 .agents/skills/csharpessentials-mediator/SKILL.md delete mode 100644 .agents/skills/csharpessentials-meta/SKILL.md delete mode 100644 .agents/skills/csharpessentials-results/SKILL.md delete mode 100644 .agents/skills/csharpessentials-rules/SKILL.md delete mode 100644 .agents/skills/csharpessentials-time/SKILL.md rename {.agents/skills => .well-known/agent-skills}/csharpessentials-validation/SKILL.md (100%) diff --git a/.agents/skills/csharpessentials-any/SKILL.md b/.agents/skills/csharpessentials-any/SKILL.md deleted file mode 100644 index cc9df64..0000000 --- a/.agents/skills/csharpessentials-any/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: csharpessentials-any -description: Use when a method can return one of several distinct types — Any as a type-safe discriminated union, implicit assignment from any branch type, exhaustive Match() to handle all cases, and Is/As for type inspection. ---- - -# CSharpEssentials.Any - -`Any` is a discriminated union — a value that is exactly one of several possible types at runtime. Replaces `object`-typed returns and eliminates unsafe casting. - -## Installation - -```bash -dotnet add package CSharpEssentials.Any -``` - -## Namespace - -```csharp -using CSharpEssentials.Any; -``` - -## Creating Any - -```csharp -// Implicit assignment — just assign the value -Any result = user; -Any result = new NotFoundError("User not found"); - -// Up to Any supported -Any outcome = order; -``` - -## Exhaustive Match - -```csharp -// All branches must be handled — compile error if one is missing -IResult response = result.Match( - whenT0: u => Ok(u), - whenT1: err => NotFound(err.Message)); - -// Async match -IResult response = await result.MatchAsync( - whenT0: async u => await BuildOkResponseAsync(u), - whenT1: async err => await BuildErrorResponseAsync(err)); -``` - -## Type Inspection - -```csharp -if (result.Is()) -{ - User user = result.As(); // safe after Is() check -} -``` - -## Typical Usage — service return type - -```csharp -public Any PlaceOrder(PlaceOrderRequest request) -{ - if (!_validator.IsValid(request)) - return new ValidationErrors(request.Errors); - - var cart = _repo.FindCart(request.CartId); - if (cart is null) - return new NotFoundError("Cart not found"); - - return _orderFactory.Create(cart); -} - -// At API boundary -var result = _service.PlaceOrder(request); -return result.Match( - whenT0: order => Created($"/orders/{order.Id}", order), - whenT1: errs => BadRequest(errs), - whenT2: err => NotFound(err.Message)); -``` - -## Best Practices - -- Use `Any` over `Result` when the error branches carry distinct, typed data -- Always use `Match()` — it enforces exhaustiveness at compile time -- `Is()` + `As()` is the escape hatch for cases where `Match()` is too verbose -- Avoid `object`-typed union members — defeats the purpose diff --git a/.agents/skills/csharpessentials-aspnetcore/SKILL.md b/.agents/skills/csharpessentials-aspnetcore/SKILL.md deleted file mode 100644 index 6709453..0000000 --- a/.agents/skills/csharpessentials-aspnetcore/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: csharpessentials-aspnetcore -description: Use when wiring CSharpEssentials Result into ASP.NET Core — GlobalExceptionHandler maps unhandled exceptions to ProblemDetails, ResultEndpointFilter converts Result returns to HTTP responses, and ConfigureSwaggerOptions adds per-version Swagger docs. ---- - -# CSharpEssentials.AspNetCore - -ASP.NET Core integration for functional patterns: error-to-ProblemDetails mapping and automatic Result-to-HTTP conversion. - -## Installation - -```bash -dotnet add package CSharpEssentials.AspNetCore -``` - -## Namespace - -```csharp -using CSharpEssentials.AspNetCore; -``` - ---- - -## GlobalExceptionHandler + ProblemDetails - -Catches unhandled exceptions and converts them to RFC 9457 ProblemDetails responses using `ErrorType → HTTP status` mapping. - -```csharp -// Program.cs -builder.Services.AddExceptionHandler(); -builder.Services.AddProblemDetails(); - -app.UseExceptionHandler(); - -// ErrorType → HTTP status mapping: -// Validation → 400 -// Unauthorized → 401 -// Forbidden → 403 -// NotFound → 404 -// Conflict → 409 -// Failure → 422 -// Unexpected → 500 -``` - ---- - -## ResultEndpointFilter - -Converts `Result` returns from minimal API handlers into HTTP responses automatically. - -```csharp -// Apply to a group -app.MapGroup("/api").AddEndpointFilter(); - -// Handler just returns Result -app.MapGet("/users/{id}", async (Guid id, UserService svc) => - await svc.GetUserAsync(id)); // returns Result - -// IsSuccess → 200 OK with JSON body -// IsFailure → ProblemDetails with status from ErrorType -``` - -Custom error mapping: - -```csharp -public class MyErrorMapper : IResultErrorMapper -{ - public int MapToStatusCode(ErrorType errorType) => errorType switch - { - ErrorType.NotFound => 404, - ErrorType.Validation => 422, - _ => 500 - }; -} - -builder.Services.AddSingleton(); -``` - ---- - -## API Versioning + Swagger - -```csharp -builder.Services.AddApiVersioning(options => -{ - options.DefaultApiVersion = new ApiVersion(1); - options.ReportApiVersions = true; -}) -.AddApiExplorer(options => -{ - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; -}); - -builder.Services.ConfigureOptions(); -builder.Services.AddSwaggerGen(); - -app.UseSwagger(); -app.UseSwaggerUI(options => -{ - foreach (var desc in app.DescribeApiVersions()) - options.SwaggerEndpoint($"/swagger/{desc.GroupName}/swagger.json", desc.GroupName); -}); -``` - ---- - -## Best Practices - -- Register `GlobalExceptionHandler` before `AddProblemDetails` -- Apply `ResultEndpointFilter` at the group level, not per-endpoint -- `error.Description` is the field name — not `error.Message` diff --git a/.agents/skills/csharpessentials-clone/SKILL.md b/.agents/skills/csharpessentials-clone/SKILL.md deleted file mode 100644 index 86851cb..0000000 --- a/.agents/skills/csharpessentials-clone/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: csharpessentials-clone -description: Use when entities need deep-copy semantics — implement ICloneable on domain objects, then call .Clone() on IEnumerable or IQueryable collections to produce independent deep copies of every element. ---- - -# CSharpEssentials.Clone - -Typed deep-copy contract for domain objects. `ICloneable` is covariant and type-safe — unlike `System.ICloneable` which returns `object`. - -## Installation - -```bash -dotnet add package CSharpEssentials.Clone -``` - -## Namespace - -```csharp -using CSharpEssentials.Clone; -``` - ---- - -## Implement ICloneable\ - -```csharp -public class Product : ICloneable -{ - public int Id { get; init; } - public string Name { get; init; } = ""; - public List Tags { get; init; } = new(); - - public Product Clone() => new() - { - Id = Id, - Name = Name, - Tags = Tags.Select(t => t.Clone()).ToList() // deep-copy child collections too - }; -} - -public class Tag : ICloneable -{ - public string Value { get; init; } = ""; - public Tag Clone() => new() { Value = Value }; -} -``` - ---- - -## Clone Collections - -Extension methods call `Clone()` on every element: - -```csharp -// IEnumerable where T : ICloneable -IEnumerable copies = products.Clone(); - -// IQueryable where T : ICloneable -IQueryable projected = dbSet.Clone(); -``` - ---- - -## Typical Use Case - -Snapshot EF Core results before applying in-memory transformations, without mutating tracked entities: - -```csharp -var snapshot = await _db.Products - .Where(p => p.CategoryId == id) - .ToListAsync(); - -var working = snapshot.Clone(); // independent deep copies — mutations don't affect EF tracking -ApplyDiscounts(working); -``` - ---- - -## Best Practices - -- Always deep-copy nested collections inside `Clone()` — a shallow copy defeats the purpose -- `ICloneable` is covariant (`out T`) — a `Product : ICloneable` satisfies `ICloneable` if needed -- Consider using `record` types with `with` expressions for immutable value objects instead of `ICloneable` -- `ICloneable` is most valuable for mutable domain objects that are tracked by EF Core diff --git a/.agents/skills/csharpessentials-core/SKILL.md b/.agents/skills/csharpessentials-core/SKILL.md deleted file mode 100644 index 81c2ccf..0000000 --- a/.agents/skills/csharpessentials-core/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: csharpessentials-core -description: Use for low-level C# utility helpers — string case conversions (ToPascalCase/ToSnakeCase/ToKebabCase), URL-safe and v7 GUID generation, null-safe collection helpers (WhereNotNull, AddIf), and async cancellation utilities. ---- - -# CSharpEssentials.Core - -Lightweight C# utility helpers. No functional patterns here — those live in the Results, Errors, Maybe, and Any skills. - -## Installation - -```bash -dotnet add package CSharpEssentials.Core -``` - -Or the meta-package (includes Core + Results + Errors + Maybe + Any): - -```bash -dotnet add package CSharpEssentials -``` - -## Namespace - -```csharp -using CSharpEssentials.Core; -``` - ---- - -## String Case Conversions - -```csharp -"helloWorld".ToPascalCase() // "HelloWorld" -"HelloWorld".ToSnakeCase() // "hello_world" -"HelloWorld".ToKebabCase() // "hello-world" -"hello-world".ToCamelCase() // "helloWorld" -``` - ---- - -## GUID Utilities - -```csharp -// URL-safe Base64 GUID (compact, URL-safe, no padding) -string id = Guider.NewGuid(); - -// Version 7 GUID — time-sortable, database index-friendly (.NET 9+) -Guid id = Guider.NewGuidV7(); -``` - ---- - -## Null-Safe / Conditional Helpers - -```csharp -// Execute action only when value is non-null -value.IfNotNull(v => process(v)); - -// Add to list conditionally -list.AddIf(condition, item); - -// Filter nulls from sequence -IEnumerable names = rawList.WhereNotNull(); - -// Cancellation-aware task awaiting -await longRunningTask.WithCancellation(ct); -``` - ---- - -## Best Practices - -- Use `Guider.NewGuidV7()` for database primary keys — time-sortable GUIDs reduce index fragmentation -- `WhereNotNull()` is safer than `.Where(x => x != null).Select(x => x!)` — handles nullable annotations correctly -- `IfNotNull()` is a statement form; for transforms use `Maybe.Map()` instead diff --git a/.agents/skills/csharpessentials-efcore/SKILL.md b/.agents/skills/csharpessentials-efcore/SKILL.md deleted file mode 100644 index b12fc63..0000000 --- a/.agents/skills/csharpessentials-efcore/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: csharpessentials-efcore -description: Use when wiring EF Core with CSharpEssentials domain models — AuditInterceptor for automatic CreatedAt/UpdatedAt, DomainEventInterceptor for post-save event dispatch, SlowQueryInterceptor for query monitoring, and ToPagedListAsync for offset pagination. ---- - -# CSharpEssentials.EntityFrameworkCore - -EF Core interceptors and pagination utilities that integrate with EntityBase and domain events automatically. - -## Installation - -```bash -dotnet add package CSharpEssentials.EntityFrameworkCore -``` - -## Namespace - -```csharp -using CSharpEssentials.EntityFrameworkCore; -``` - ---- - -## Interceptors - -Register in `DbContext.OnConfiguring`: - -```csharp -protected override void OnConfiguring(DbContextOptionsBuilder options) -{ - options - .AddInterceptors(new AuditInterceptor(auditUserIdProvider)) - .AddInterceptors(new DomainEventInterceptor(eventPublisher)) - .AddInterceptors(new SlowQueryInterceptor(slowQueryHandler, TimeSpan.FromSeconds(5))); -} -``` - -Register `AuditInterceptor` before `DomainEventInterceptor` in the chain. - -### AuditInterceptor - -Auto-sets `CreatedAt`, `CreatedBy`, `UpdatedAt`, `UpdatedBy` on `EntityBase` entries during `SaveChanges`. - -```csharp -public class MyAuditProvider : IAuditUserIdProvider -{ - private readonly IHttpContextAccessor _accessor; - public MyAuditProvider(IHttpContextAccessor accessor) => _accessor = accessor; - - public string GetUserId() => - _accessor.HttpContext?.User?.Identity?.Name ?? "system"; -} - -builder.Services.AddScoped(); -``` - -### DomainEventInterceptor - -Dispatches `IDomainEvent`s raised on entities after `SaveChanges` completes. - -```csharp -public class MyEventPublisher : IDomainEventPublisher -{ - private readonly IMediator _mediator; - public MyEventPublisher(IMediator mediator) => _mediator = mediator; - - public Task PublishAsync(IDomainEvent domainEvent, CancellationToken ct) => - _mediator.Publish(domainEvent, ct); -} - -builder.Services.AddScoped(); -``` - -### SlowQueryInterceptor - -Invokes a handler when a query exceeds the configured threshold. - -```csharp -public class MySlowQueryHandler : ISlowQueryHandler -{ - public Task HandleAsync(string sql, TimeSpan elapsed, CancellationToken ct) - { - _logger.LogWarning("Slow query ({Elapsed}ms): {Sql}", elapsed.TotalMilliseconds, sql); - return Task.CompletedTask; - } -} -``` - ---- - -## Pagination - -```csharp -// Offset-based paging -var page = await _db.Orders - .OrderByDescending(o => o.CreatedAt) - .ToPagedListAsync(pageNumber: 1, pageSize: 20); - -page.Items // IReadOnlyList -page.TotalCount // int — total records (ignoring pagination) -page.PageNumber // int -page.TotalPages // int -page.HasNextPage // bool -page.HasPreviousPage // bool -``` - ---- - -## Best Practices - -- Register `AuditInterceptor` before `DomainEventInterceptor` — audit fields must be set before events fire -- Add a global EF query filter for `IsDeleted = false` to exclude soft-deleted records automatically -- Use `[DomainEventTiming(BeforeSave)]` on events that must validate before the transaction commits -- `ToPagedListAsync` issues two SQL queries (COUNT + data) — add appropriate indexes on sort columns diff --git a/.agents/skills/csharpessentials-entity/SKILL.md b/.agents/skills/csharpessentials-entity/SKILL.md deleted file mode 100644 index 19918f3..0000000 --- a/.agents/skills/csharpessentials-entity/SKILL.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: csharpessentials-entity -description: Use when building DDD domain models — EntityBase for aggregate roots with audit fields and domain events, SoftDeletableEntityBase for soft deletion lifecycle, and IDomainEvent for defining and raising domain events. ---- - -# CSharpEssentials.Entity - -DDD base classes for aggregate roots. Built-in audit tracking, soft deletion, and domain event support. - -## Installation - -```bash -dotnet add package CSharpEssentials.Entity -``` - -## Namespaces - -```csharp -using CSharpEssentials.Entity; // EntityBase, SoftDeletableEntityBase -using CSharpEssentials.Entity.Interfaces; // IDomainEvent, ISoftDeletable, IEntityBase -``` - ---- - -## EntityBase\ - -```csharp -public class Order : EntityBase -{ - public string CustomerId { get; private set; } = default!; - public decimal Total { get; private set; } - - public static Order Create(string customerId, decimal total) - { - var order = new Order { Id = Guid.NewGuid(), CustomerId = customerId, Total = total }; - order.Raise(new OrderCreatedEvent(order.Id, customerId)); - return order; - } -} - -// Provided members: -// TId? Id -// DateTimeOffset CreatedAt — set by AuditInterceptor -// string? CreatedBy — set by AuditInterceptor -// DateTimeOffset? UpdatedAt — NOT ModifiedAt -// string? UpdatedBy — NOT ModifiedBy -// IReadOnlyList DomainEvents — property, NOT a method -// void Raise(IDomainEvent) -// void ClearDomainEvents() -``` - ---- - -## SoftDeletableEntityBase\ - -```csharp -public class Product : SoftDeletableEntityBase -{ - public string Name { get; private set; } = default!; -} - -// Soft delete lifecycle -product.MarkAsDeleted(DateTimeOffset.UtcNow, "admin"); // two params: (deletedAt, deletedBy) -product.Restore(); // undoes soft delete -product.MarkAsHardDeleted(); // irreversible - -// Additional members: -// DateTimeOffset? DeletedAt -// string? DeletedBy -// bool IsDeleted -// bool IsHardDeleted -``` - ---- - -## Domain Events - -```csharp -// Define — implement IDomainEvent -public record OrderCreatedEvent(Guid OrderId, string CustomerId) : IDomainEvent; - -// Control publish timing -[DomainEventTiming(DomainEventTiming.BeforeSave)] -public record InventoryReservedEvent(Guid ProductId, int Qty) : IDomainEvent; - -// Raise inside the aggregate -order.Raise(new OrderCreatedEvent(order.Id, customerId)); - -// Read and clear (after publishing) -IReadOnlyList events = order.DomainEvents; // property -order.ClearDomainEvents(); -``` - ---- - -## Best Practices - -- Call `Raise()` only inside entity methods — keep domain events encapsulated in the aggregate -- `DomainEvents` is a **property** — do not call `GetDomainEvents()` (doesn't exist) -- Audit fields are `UpdatedAt`/`UpdatedBy` — **not** `ModifiedAt`/`ModifiedBy` -- `MarkAsDeleted()` takes **two parameters**: `(DateTimeOffset deletedAt, string deletedBy)` -- Use `DomainEventTiming.BeforeSave` for events that must be processed before the transaction commits diff --git a/.agents/skills/csharpessentials-enums/SKILL.md b/.agents/skills/csharpessentials-enums/SKILL.md deleted file mode 100644 index a46fd2c..0000000 --- a/.agents/skills/csharpessentials-enums/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: csharpessentials-enums -description: Use when you need enum-to-string serialization without reflection — [StringEnum] source generator produces compile-time ToString(), Parse(), and TryParse() methods that are NativeAOT-safe and zero-allocation. ---- - -# CSharpEssentials.Enums - -`[StringEnum]` is a source generator attribute that produces fast, reflection-free string conversion methods for enum types. Safe for NativeAOT and Blazor WASM. - -## Installation - -```bash -dotnet add package CSharpEssentials.Enums -``` - -## Namespace - -```csharp -using CSharpEssentials.Enums; -``` - -## Usage - -```csharp -[StringEnum] -public enum OrderStatus { Pending, Processing, Shipped, Delivered, Cancelled } - -[StringEnum] -public enum UserRole { Admin, Editor, Viewer } -``` - -The source generator emits at compile time: - -```csharp -// Generated methods (no reflection, no allocations) -string s = OrderStatus.Shipped.ToStringFast(); // "Shipped" -bool parsed = OrderStatus.TryParse("Shipped", out OrderStatus status); -OrderStatus s = OrderStatus.Parse("Shipped"); // throws on unknown value -``` - -## JSON Integration - -`ConditionalStringEnumConverter` (in `CSharpEssentials.Json`) serializes `[StringEnum]`-decorated enums as strings and all others as integers: - -```csharp -// In JsonOptions setup -options.Converters.Add(new ConditionalStringEnumConverter()); - -// OrderStatus (has [StringEnum]) → "Shipped" in JSON -// HttpMethod (no [StringEnum]) → 2 in JSON -``` - -## Best Practices - -- Apply `[StringEnum]` to any enum that appears in API responses, logs, or database columns as text -- Combine with `ConditionalStringEnumConverter` so string enums serialize naturally in ASP.NET Core -- Generated methods are NativeAOT-safe — no `RuntimeReflectionExtensions` involved diff --git a/.agents/skills/csharpessentials-errors/SKILL.md b/.agents/skills/csharpessentials-errors/SKILL.md deleted file mode 100644 index e94ce37..0000000 --- a/.agents/skills/csharpessentials-errors/SKILL.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -name: csharpessentials-errors -description: Use when creating structured error values — Error factory methods (Failure/Validation/NotFound/Conflict/Unauthorized/Forbidden/Unexpected), ErrorMetadata for contextual data, HTTP status mapping, and domain-specific static error class hierarchies. ---- - -# CSharpEssentials.Errors - -Errors are values, not exceptions. `Error` is a `readonly record struct` with a code, description, type, and optional metadata. - -## Installation - -```bash -dotnet add package CSharpEssentials.Errors -``` - -## Namespace - -```csharp -using CSharpEssentials.Errors; -``` - -## Creating Errors - -```csharp -// ErrorType: Failure | Unexpected | Validation | Conflict | NotFound | Unauthorized | Forbidden -Error.Failure("order.failed", "Order could not be processed.") -Error.Validation("email.invalid","Email format is invalid.") -Error.NotFound("user.not_found", "User not found.") -Error.Conflict("email.taken", "Email is already registered.") -Error.Unauthorized("token.expired", "Token has expired.") -Error.Forbidden("access.denied", "Insufficient permissions.") -Error.Unexpected("sys.error", "An unexpected error occurred.") -Error.Exception(ex) // wrap exception → Error -``` - -## Error Properties - -```csharp -error.Code // "email.invalid" -error.Description // "Email format is invalid." — NOT .Message -error.Type // ErrorType.Validation -error.Metadata // ErrorMetadata? (nullable) -``` - -## ErrorMetadata - -```csharp -Error withMeta = Error.NotFound( - "User.NotFound", - "User was not found.", - new ErrorMetadata() - .AddMetadata("TraceId", traceId) - .AddMetadata("RequestPath", "/api/users/1")); -// .AddMetadata() — NOT .WithMetadata() -``` - -## Combining Errors - -```csharp -Error[] merged = error1 + error2; // operator + -Error[] many = Error.CreateMany(e1, e2, e3); -``` - -## HTTP Status Mapping - -```csharp -int status = ErrorType.NotFound.ToHttpStatusCode(); // 404 -int status2 = ErrorType.Validation.ToHttpStatusCode(); // 400 -ErrorType et = 401.ToErrorType(); // Unauthorized -``` - -## Domain-Specific Error Hierarchies - -`Error` is a `readonly record struct` — it cannot be subclassed. Use static classes per aggregate: - -```csharp -public static class UserErrors -{ - public static Error NotFound(Guid id) => - Error.NotFound("User.NotFound", $"User '{id}' was not found."); - - public static readonly Error AlreadyExists = - Error.Conflict("User.AlreadyExists", "A user with that email already exists."); - - public static Error InvalidAge(int age) => - Error.Validation("User.InvalidAge", $"Age {age} is invalid; must be 18 or older."); - - public static readonly Error Unauthorized = - Error.Unauthorized("User.Unauthorized", "You are not authorized to perform this action."); -} - -public static class OrderErrors -{ - public static readonly Error EmptyCart = - Error.Validation("Order.EmptyCart", "Cannot place an order with an empty cart."); - - public static Error InsufficientFunds(decimal required, decimal available) => - Error.Failure( - "Order.InsufficientFunds", - $"Payment requires {required:C} but only {available:C} available.", - new ErrorMetadata() - .AddMetadata("Required", required) - .AddMetadata("Available", available)); -} - -// Usage — implicit Error → Result -public Result FindUser(Guid id) -{ - User? user = _repo.Find(id); - return user is null ? UserErrors.NotFound(id) : user; -} -``` - -## Domain Exceptions - -```csharp -using CSharpEssentials.Exceptions; - -throw new DomainException(Error.Validation("Order.Invalid", "Total must be greater than zero.")); -``` - -## Best Practices - -- Group errors in static classes per aggregate for IDE autocomplete + type-safe codes -- Use `error.Description` — the field is named `Description`, not `Message` -- `ErrorMetadata` uses `.AddMetadata()` — there is no `.WithMetadata()` -- Prefer factory methods (parameterized) over static readonly fields when the message includes runtime data -- `Error` is a value type — safe to use as dictionary key, in switch expressions, etc. diff --git a/.agents/skills/csharpessentials-gcpsecretmanager/SKILL.md b/.agents/skills/csharpessentials-gcpsecretmanager/SKILL.md deleted file mode 100644 index 58b2f7c..0000000 --- a/.agents/skills/csharpessentials-gcpsecretmanager/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: csharpessentials-gcpsecretmanager -description: Use when loading secrets from Google Cloud Secret Manager into IConfiguration at startup — AddGcpSecretManager() registers a configuration provider that pulls named secrets so they are available as standard config values throughout the application. ---- - -# CSharpEssentials.GcpSecretManager - -Configuration provider that loads secrets from Google Cloud Secret Manager into the standard `IConfiguration` system. Secrets become available like any other config value after startup. - -## Installation - -```bash -dotnet add package CSharpEssentials.GcpSecretManager -``` - -## Namespace - -```csharp -using CSharpEssentials.GcpSecretManager; -``` - ---- - -## Register - -```csharp -// Program.cs — add before building the app -builder.Configuration.AddGcpSecretManager(options => -{ - options.AddProject(new ProjectSecretConfiguration - { - ProjectId = "my-gcp-project", - SecretIds = ["db-connection-string", "stripe-api-key", "jwt-secret"] - }); -}); - -// Secrets available anywhere via IConfiguration -var connStr = config["db-connection-string"]; -var apiKey = config["stripe-api-key"]; - -// Or via IOptions / strongly-typed binding -builder.Services.Configure(builder.Configuration.GetSection("Database")); -``` - ---- - -## Authentication - -Uses Application Default Credentials (ADC). In GCP environments (Cloud Run, GKE, Compute Engine), the service account is used automatically. For local development: - -```bash -gcloud auth application-default login -``` - ---- - -## Best Practices - -- Add `AddGcpSecretManager()` after `AddJsonFile()` calls so GCP secrets override local config -- Use secret names that match `IConfiguration` key conventions (`db-connection-string` → `config["db-connection-string"]`) -- In production, grant the service account the `Secret Manager Secret Accessor` IAM role only — not `Viewer` -- Do not list secrets in `appsettings.json` — the point is to keep them out of source control diff --git a/.agents/skills/csharpessentials-http/SKILL.md b/.agents/skills/csharpessentials-http/SKILL.md deleted file mode 100644 index 6d74228..0000000 --- a/.agents/skills/csharpessentials-http/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: csharpessentials-http -description: Use when making HTTP calls that should return Result instead of throwing exceptions — GetFromJsonResultAsync, PostAsJsonResultAsync, DeleteResultAsync on HttpClient, and HttpRequestBuilder for fluent multi-header/query-param requests with optional Polly resilience. ---- - -# CSharpEssentials.Http - -HttpClient extensions that return `Result` instead of throwing on 4xx/5xx. Never catch `HttpRequestException` again. - -## Installation - -```bash -dotnet add package CSharpEssentials.Http -``` - -## Namespace - -```csharp -using CSharpEssentials.Http; -``` - ---- - -## Result-Returning Extensions - -```csharp -// Register typed client -builder.Services.AddHttpClient(c => - c.BaseAddress = new Uri("https://api.example.com")); - -// GET -Result result = await _client.GetFromJsonResultAsync("/users/1"); - -// POST -Result posted = await _client.PostAsJsonResultAsync("/orders", newOrder); - -// PUT -Result updated = await _client.PutAsJsonResultAsync("/users/1", userDto); - -// DELETE -Result deleted = await _client.DeleteResultAsync("/orders/1"); - -// All methods: 2xx → Success, 4xx/5xx → Failure with Error describing the HTTP status -``` - ---- - -## HttpRequestBuilder — fluent complex requests - -```csharp -var result = await new HttpRequestBuilder(_client) - .WithUrl("/search") - .WithQueryParam("q", query) - .WithQueryParam("page", "1") - .WithHeader("X-Api-Key", apiKey) - .WithHeader("X-Correlation-Id", correlationId) - .GetAsync(); -``` - ---- - -## Resilience (Polly) - -```csharp -builder.Services.AddHttpClient() - .AddRetryPolicy(retryCount: 3) - .AddCircuitBreakerPolicy( - handledEventsAllowedBeforeBreaking: 5, - durationOfBreak: TimeSpan.FromSeconds(30)); -``` - ---- - -## Best Practices - -- Prefer `HttpRequestBuilder` over raw `HttpClient` for multi-header or multi-param requests -- Combine with `Result.ThenAsync()` to chain downstream calls without nested try/catch -- Use typed `HttpClient` classes rather than `IHttpClientFactory` directly for testability diff --git a/.agents/skills/csharpessentials-json/SKILL.md b/.agents/skills/csharpessentials-json/SKILL.md deleted file mode 100644 index a1187f9..0000000 --- a/.agents/skills/csharpessentials-json/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: csharpessentials-json -description: Use when configuring System.Text.Json for ASP.NET Core — JsonOptions.Default with camelCase/no-nulls/no-cycles, ConditionalStringEnumConverter for [StringEnum] enums, MultiFormatDateTimeConverter for flexible date parsing, and PolymorphicJsonConverterFactory for $type discriminator. ---- - -# CSharpEssentials.Json - -Pre-configured `System.Text.Json` options and converters for common ASP.NET Core patterns. - -## Installation - -```bash -dotnet add package CSharpEssentials.Json -``` - -## Namespace - -```csharp -using CSharpEssentials.Json; -``` - ---- - -## JsonOptions.Default - -Pre-configured profile: camelCase property names, ignore null values, handle circular references. - -```csharp -// In ASP.NET Core -builder.Services.AddControllers() - .AddJsonOptions(o => o.JsonSerializerOptions.ApplyDefaults()); - -// Standalone serialization -var json = JsonSerializer.Serialize(obj, JsonOptions.Default); -var obj = JsonSerializer.Deserialize(json, JsonOptions.Default); -``` - ---- - -## ConditionalStringEnumConverter - -Serializes enums marked with `[StringEnum]` (from `CSharpEssentials.Enums`) as strings, and all other enums as integers. - -```csharp -// In setup -options.Converters.Add(new ConditionalStringEnumConverter()); - -// [StringEnum] enum → "Shipped" in JSON -// Regular enum → 2 in JSON -``` - ---- - -## MultiFormatDateTimeConverter - -Deserializes `DateTime` / `DateTimeOffset` from multiple input formats (ISO 8601, custom patterns). Useful when consuming third-party APIs with inconsistent date formats. - -```csharp -options.Converters.Add(new MultiFormatDateTimeConverter()); -``` - ---- - -## PolymorphicJsonConverterFactory - -Enables polymorphic deserialization using a `$type` discriminator field. - -```csharp -options.Converters.Add(new PolymorphicJsonConverterFactory()); - -// JSON: { "$type": "Circle", "radius": 5 } -// Deserializes to Circle : Shape -``` - ---- - -## Best Practices - -- Call `ApplyDefaults()` in one place — do not configure `JsonSerializerOptions` in multiple locations -- `ConditionalStringEnumConverter` requires enums to be decorated with `[StringEnum]` from `CSharpEssentials.Enums` -- `PolymorphicJsonConverterFactory` requires the discriminator field to be named `$type` diff --git a/.agents/skills/csharpessentials-logging/SKILL.md b/.agents/skills/csharpessentials-logging/SKILL.md deleted file mode 100644 index 69522d2..0000000 --- a/.agents/skills/csharpessentials-logging/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: csharpessentials-logging -description: Use when adding request/response body logging middleware to ASP.NET Core — AddRequestResponseLogging() with configurable body/header capture, UseRequestResponseLogging() pipeline registration, and [SkipRequestResponseLogging] / [SkipRequestLogging] / [SkipResponseLogging] attributes for per-endpoint opt-out. ---- - -# CSharpEssentials.RequestResponseLogging - -Middleware that logs HTTP request and response bodies. Configurable per-endpoint via attributes. - -## Installation - -```bash -dotnet add package CSharpEssentials.RequestResponseLogging -``` - -## Namespace - -```csharp -using CSharpEssentials.RequestResponseLogging; -``` - ---- - -## Register and Use - -```csharp -// Program.cs -builder.Services.AddRequestResponseLogging(options => -{ - options.Request.LogBody = true; - options.Request.LogHeaders = false; - options.Response.LogBody = true; - options.IgnorePaths = ["/health", "/metrics", "/favicon.ico"]; -}); - -app.UseRequestResponseLogging(); -``` - ---- - -## Per-Endpoint Opt-Out - -```csharp -[SkipRequestResponseLogging] // skip both request and response -[SkipRequestLogging] // skip request body only -[SkipResponseLogging] // skip response body only -public IActionResult MyAction() { ... } -``` - -Apply to individual controller actions, Minimal API handlers, or entire controllers. - ---- - -## Best Practices - -- Set `LogBody = false` for endpoints handling auth, passwords, or PII -- Always add health check and metrics paths to `IgnorePaths` — these are high-frequency and low-value -- Apply `[SkipRequestResponseLogging]` rather than `[SkipRequestLogging]` + `[SkipResponseLogging]` when skipping both -- Register `UseRequestResponseLogging()` early in the pipeline, before `UseRouting()` diff --git a/.agents/skills/csharpessentials-maybe/SKILL.md b/.agents/skills/csharpessentials-maybe/SKILL.md deleted file mode 100644 index 607969f..0000000 --- a/.agents/skills/csharpessentials-maybe/SKILL.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -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. ---- - -# CSharpEssentials.Maybe - -`Maybe` makes optionality explicit. No null reference exceptions — the absence of a value is a first-class concept. - -## Installation - -```bash -dotnet add package CSharpEssentials.Maybe -``` - -## Namespace - -```csharp -using CSharpEssentials.Maybe; -``` - -## Creating Maybe - -```csharp -Maybe name = Maybe.From(user?.Name); // null → None, value → Some -Maybe none = Maybe.None; -Maybe some = Maybe.From("Alice"); -Maybe implicit = user.Name; // implicit T? → Maybe -``` - -`Maybe.From(null)` → `None`. `Maybe.From(value)` → `Some(value)`. Never use `.ToMaybe()` — that method does not exist. - -## Checking Value - -```csharp -bool has = maybe.HasValue; -bool empty = maybe.HasNoValue; -string val = maybe.GetValueOrDefault("fallback"); -string val = maybe.GetValueOrThrow(); // throws if None -``` - -## Pattern Match - -```csharp -string result = maybe.Match( - some: name => $"Hello, {name}", - none: () => "Hello, stranger"); -``` - -## Transforming - -```csharp -// Map: transform the inner value if present -string display = Maybe.From(user?.Email) - .Map(e => e.ToLowerInvariant()) - .GetValueOrDefault("no email"); - -// Bind: flatMap — when the transform itself returns Maybe -Maybe
address = Maybe.From(user) - .Bind(u => Maybe.From(u?.Address)); -``` - -## Bridge to Result - -```csharp -// Convert Maybe → Result, providing the error for the None case -Result r = maybe.ToMaybeResult( - Error.NotFound("user.email", "No email address on file.")); -``` - -## Best Practices - -- Use `Maybe.From()` — not `.ToMaybe()` (doesn't exist) -- Prefer `Match()` over `HasValue` + `GetValueOrThrow()` to avoid branches -- Use `Bind()` when the transform itself can be absent (returns `Maybe`) -- Bridge to `Result` with `ToMaybeResult()` when the caller needs error information diff --git a/.agents/skills/csharpessentials-mediator/SKILL.md b/.agents/skills/csharpessentials-mediator/SKILL.md deleted file mode 100644 index 3885d6d..0000000 --- a/.agents/skills/csharpessentials-mediator/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: csharpessentials-mediator -description: Use when adding cross-cutting pipeline behaviors to CQRS handlers — ValidationBehavior (CSharpEssentials.Validation, throws EnhancedValidationException), LoggingBehavior (ILoggableRequest), CachingBehavior (ICacheable with IDistributedCache), and TransactionScopeBehavior (ITransactionalRequest). ---- - -# CSharpEssentials.Mediator - -Pipeline behaviors for the Mediator source-generator library. Register cross-cutting concerns (validation, logging, caching, transactions) once — they run automatically for every matching handler. - -> Built on the **Mediator** source-generator NuGet package — not MediatR. - -## Installation - -```bash -dotnet add package CSharpEssentials.Mediator -``` - -## Namespace - -```csharp -using CSharpEssentials.Mediator; // ICacheable, ILoggableRequest, ITransactionalRequest -using Microsoft.Extensions.DependencyInjection; -``` - -## Register Behaviors - -```csharp -// Program.cs -builder.Services.AddMediator(); // Mediator source generator -builder.Services.AddMediatorBehaviors(); // all 4 behaviors - -// Or selectively -builder.Services.AddMediatorValidationBehavior(); -builder.Services.AddMediatorLoggingBehavior(); -builder.Services.AddMediatorCachingBehavior(); -builder.Services.AddMediatorTransactionBehavior(); -``` - -Register `ValidationBehavior` first — invalid requests should never reach the handler. - ---- - -## ValidationBehavior — CSharpEssentials.Validation - -Runs registered validators before the handler. On failure, the handler is never invoked. Errors are surfaced based on the handler return type: - -| `TResponse` | Failure result | -|-------------|---------------| -| `Result` | `Result.Failure(errors)` returned directly | -| `Result` | `Result.Failure(errors)` returned directly | -| Any other type | `EnhancedValidationException` thrown — caught by `GlobalExceptionHandler` | - -```csharp -public class CreateOrderValidator : Validator -{ - protected override ValueTask Configure(CreateOrderCommand model, RuleContext rules, CancellationToken ct = default) - { - rules.For(() => model.CustomerId).NotEmpty(); - rules.For(() => model.Total).GreaterThan(0); - return ValueTask.CompletedTask; - } -} - -builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly); -``` - ---- - -## LoggingBehavior — ILoggableRequest - -Interface hierarchy: - -```csharp -// ILoggableRequest — base marker -// IRequestLoggable — logs request body only -// IResponseLoggable — logs response body only -// IRequestResponseLoggable — logs both -``` - -```csharp -public record GetUserQuery(Guid UserId) - : IQuery>, IRequestResponseLoggable; - -public record SendEmailCommand(string To, string Body) - : ICommand, IRequestLoggable; // response has no PII — log request only -``` - ---- - -## CachingBehavior — ICacheable - -Requires `IDistributedCache` registration. - -```csharp -public record GetProductQuery(int ProductId) - : IQuery>, ICacheable -{ - public bool BypassCache => false; - public bool CacheFailures => false; // never cache error results - public string CacheKey => $"product:{ProductId}"; - public TimeSpan Expiration => TimeSpan.FromMinutes(5); -} - -// Requires a cache backend -builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redisConn); -// or: -builder.Services.AddDistributedMemoryCache(); -``` - ---- - -## TransactionBehavior — ITransactionalRequest - -Wraps the handler in `TransactionScope` (ReadCommitted + AsyncFlowEnabled). Commits on success, rolls back on failure or exception. - -```csharp -public record PlaceOrderCommand(OrderDto Order) - : ICommand>, ITransactionalRequest; -// No members to implement on ITransactionalRequest -``` - ---- - -## Best Practices - -- Register `ValidationBehavior` first — invalid requests should never reach the handler -- Set `CacheFailures = false` — transient failures should not be cached -- `ITransactionalRequest` only on commands writing to multiple tables in one operation -- Use `IRequestLoggable` (not `IRequestResponseLoggable`) when the response contains PII diff --git a/.agents/skills/csharpessentials-meta/SKILL.md b/.agents/skills/csharpessentials-meta/SKILL.md deleted file mode 100644 index 8e016d1..0000000 --- a/.agents/skills/csharpessentials-meta/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: csharpessentials-meta -description: Use when deciding which CSharpEssentials package to use — overview of all 19 packages organized by concern, the meta-package that bundles core functional modules, and a quick-reference table mapping problems to packages. ---- - -# CSharpEssentials — Package Index - -CSharpEssentials is a modular .NET NuGet ecosystem. Each package is independent — take only what you need. - -## Meta-Package (core functional modules) - -```bash -dotnet add package CSharpEssentials -# Includes: Results, Errors, Maybe, Any, Core, Enums -``` - -## All Packages - -### Functional Core - -| Package | Install | Skill | -|---------|---------|-------| -| `CSharpEssentials.Results` | `dotnet add package CSharpEssentials.Results` | `csharpessentials-results` | -| `CSharpEssentials.Errors` | `dotnet add package CSharpEssentials.Errors` | `csharpessentials-errors` | -| `CSharpEssentials.Maybe` | `dotnet add package CSharpEssentials.Maybe` | `csharpessentials-maybe` | -| `CSharpEssentials.Any` | `dotnet add package CSharpEssentials.Any` | `csharpessentials-any` | -| `CSharpEssentials.Core` | `dotnet add package CSharpEssentials.Core` | `csharpessentials-core` | -| `CSharpEssentials.Enums` | `dotnet add package CSharpEssentials.Enums` | `csharpessentials-enums` | - -### Business Rules & Validation - -| Package | Install | Skill | -|---------|---------|-------| -| `CSharpEssentials.Rules` | `dotnet add package CSharpEssentials.Rules` | `csharpessentials-rules` | -| `CSharpEssentials.Validation` | `dotnet add package CSharpEssentials.Validation` | `csharpessentials-validation` | - -### CQRS / Mediator - -| Package | Install | Skill | -|---------|---------|-------| -| `CSharpEssentials.Mediator` | `dotnet add package CSharpEssentials.Mediator` | `csharpessentials-mediator` | - -### Domain Model / EF Core - -| Package | Install | Skill | -|---------|---------|-------| -| `CSharpEssentials.Entity` | `dotnet add package CSharpEssentials.Entity` | `csharpessentials-entity` | -| `CSharpEssentials.EntityFrameworkCore` | `dotnet add package CSharpEssentials.EntityFrameworkCore` | `csharpessentials-efcore` | - -### Web / Infrastructure - -| Package | Install | Skill | -|---------|---------|-------| -| `CSharpEssentials.AspNetCore` | `dotnet add package CSharpEssentials.AspNetCore` | `csharpessentials-aspnetcore` | -| `CSharpEssentials.Http` | `dotnet add package CSharpEssentials.Http` | `csharpessentials-http` | -| `CSharpEssentials.Json` | `dotnet add package CSharpEssentials.Json` | `csharpessentials-json` | -| `CSharpEssentials.RequestResponseLogging` | `dotnet add package CSharpEssentials.RequestResponseLogging` | `csharpessentials-logging` | -| `CSharpEssentials.GcpSecretManager` | `dotnet add package CSharpEssentials.GcpSecretManager` | `csharpessentials-gcpsecretmanager` | - -### Utilities - -| Package | Install | Skill | -|---------|---------|-------| -| `CSharpEssentials.Time` | `dotnet add package CSharpEssentials.Time` | `csharpessentials-time` | -| `CSharpEssentials.Clone` | `dotnet add package CSharpEssentials.Clone` | `csharpessentials-clone` | - ---- - -## Problem → Package Quick Reference - -| Problem | Package | -|---------|---------| -| Return errors without exceptions | `CSharpEssentials.Results` + `CSharpEssentials.Errors` | -| Represent optional values (no null) | `CSharpEssentials.Maybe` | -| Return one of several distinct types | `CSharpEssentials.Any` | -| Compose business validation rules | `CSharpEssentials.Rules` | -| Model-first validation returning `Result` | `CSharpEssentials.Validation` | -| CQRS pipeline behaviors (validate, log, cache, transact) | `CSharpEssentials.Mediator` | -| DDD aggregate base class + domain events | `CSharpEssentials.Entity` | -| EF Core audit, slow queries, pagination | `CSharpEssentials.EntityFrameworkCore` | -| Map errors to HTTP ProblemDetails | `CSharpEssentials.AspNetCore` | -| HttpClient that returns Result | `CSharpEssentials.Http` | -| JSON serialization with string enums + polymorphism | `CSharpEssentials.Json` | -| Log request/response bodies | `CSharpEssentials.RequestResponseLogging` | -| Load secrets from GCP Secret Manager | `CSharpEssentials.GcpSecretManager` | -| Testable time / freeze clock in tests | `CSharpEssentials.Time` | -| Deep-copy entity collections | `CSharpEssentials.Clone` | -| Fast enum-to-string (NativeAOT-safe) | `CSharpEssentials.Enums` | -| String case conversions, GUID utilities | `CSharpEssentials.Core` | - ---- - -## Namespace Reference - -```csharp -using CSharpEssentials.ResultPattern; // Result, Result -using CSharpEssentials.Errors; // Error, ErrorType, ErrorMetadata -using CSharpEssentials.Maybe; // Maybe -using CSharpEssentials.Any; // Any -using CSharpEssentials.Core; // string/GUID/collection helpers -using CSharpEssentials.Enums; // [StringEnum] -using CSharpEssentials.Rules; // IRule, RuleEngine -using CSharpEssentials.Validation; // Validator, RuleContext, IValidator -using CSharpEssentials.Mediator; // ICacheable, ILoggableRequest, ITransactionalRequest -using CSharpEssentials.Entity; // EntityBase, SoftDeletableEntityBase -using CSharpEssentials.Entity.Interfaces; // IDomainEvent -using CSharpEssentials.EntityFrameworkCore; // interceptors, pagination -using CSharpEssentials.AspNetCore; // GlobalExceptionHandler, ResultEndpointFilter -using CSharpEssentials.Http; // HttpClientResultExtensions, HttpRequestBuilder -using CSharpEssentials.Json; // JsonOptions, converters -using CSharpEssentials.RequestResponseLogging; // LoggingOptions, SkipLoggingAttributes -using CSharpEssentials.GcpSecretManager; // AddGcpSecretManager() -using CSharpEssentials.Time; // IDateTimeProvider, DateTimeProvider -using CSharpEssentials.Clone; // ICloneable -``` diff --git a/.agents/skills/csharpessentials-results/SKILL.md b/.agents/skills/csharpessentials-results/SKILL.md deleted file mode 100644 index 3f84554..0000000 --- a/.agents/skills/csharpessentials-results/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: csharpessentials-results -description: Use when handling operation outcomes without exceptions — Result and Result for success/failure, railway-oriented chaining with Then/ThenAsync/Ensure, Match for consumption, and Result.And/Or for combining multiple results. ---- - -# CSharpEssentials.Results - -`Result` and `Result` model operation outcomes as values. No exceptions for control flow. - -## Installation - -```bash -dotnet add package CSharpEssentials.Results -``` - -## Namespace - -```csharp -using CSharpEssentials.ResultPattern; -using CSharpEssentials.Errors; -``` - -## Creating Results - -```csharp -// Success -Result ok = Result.Success(); -Result v = Result.Success(42); - -// Failure — explicit factory -Result fail = Result.Failure(Error.Validation("Input.Invalid", "Input was invalid.")); -Result fail1 = Result.Failure(Error.NotFound("User.NotFound", "User not found.")); -Result fail2 = Result.Failure(Error.Conflict("User.Duplicate", "Duplicate.")); - -// Multiple errors in one failure -Result multi = Result.Failure( - Error.Validation("Name.Empty", "Name is required."), - Error.Validation("Email.Invalid", "Email is invalid.")); - -// Implicit conversions — shorthand -Result r = user; // T → Result -Result r = Error.NotFound("...", "..."); // Error → Result -``` - -## Checking the Result - -```csharp -if (result.IsFailure) - return result.FirstError; // Error (first in list) - -if (result.IsSuccess) - return result.Value; // T — safe only after IsSuccess check -``` - -## Chaining — railway-oriented - -```csharp -// Then: transform value, short-circuits on failure -Result result = Parse("5") - .Then(n => n * 2) - .Then(n => n + 10); - -// ThenAsync: async chain -Result placed = await GetUserAsync(id) - .ThenAsync(user => ValidateOrderAsync(user, order)) - .ThenAsync(order => ChargePaymentAsync(order)); - -// Ensure: guard condition — adds error if predicate fails -Result ensured = Result.Success(50) - .Ensure(v => v > 0, Error.Validation("Range", "Must be positive.")) - .Ensure(v => v < 100, Error.Validation("Range", "Must be less than 100.")); - -// EnsureAsync -Result validated = await GetUserAsync(id) - .EnsureAsync(u => IsActiveAsync(u), Error.Validation("User.Inactive", "Account is inactive.")); -``` - -## Consuming — Match - -```csharp -string msg = result.Match( - onSuccess: value => $"OK: {value}", - onError: errors => $"Failed: {errors[0].Description}"); // errors is Error[] - -// Async match -await result.MatchAsync( - onSuccess: async value => await SendConfirmationAsync(value), - onError: async errors => await LogErrorsAsync(errors)); -``` - -## Combining Results - -```csharp -// And — all must pass (short-circuits on first failure) -Result combined = Result.And(r1, r2, r3); - -// Or — first success wins -Result any = Result.Or(r1, r2, r3); -``` - -## Safe Execution - -```csharp -// Wrap exception → Result -Result safe = Result.Try(() => int.Parse(input), ex => Error.Exception(ex)); - -// Async -Result data = await Result.TryAsync(() => _db.GetAsync(id), ex => Error.Exception(ex)); -``` - -## Best Practices - -- Never access `.Value` without checking `.IsSuccess` first -- `onError` in `Match` receives `Error[]` (array) — not a single `Error` -- `Then()` short-circuits: once a failure occurs, subsequent `Then()` calls are skipped -- Prefer `Result.Failure` over the implicit `Error → Result` conversion when self-documentation matters -- Use `Ensure()` to add guard conditions without breaking the chain diff --git a/.agents/skills/csharpessentials-rules/SKILL.md b/.agents/skills/csharpessentials-rules/SKILL.md deleted file mode 100644 index 6da2f50..0000000 --- a/.agents/skills/csharpessentials-rules/SKILL.md +++ /dev/null @@ -1,230 +0,0 @@ ---- -name: csharpessentials-rules -description: Use when composing business validation logic — define rules as classes, Func fields, or inline lambdas; combine with .And()/.Or()/.Linear()/.Next(); evaluate with RuleEngine.Evaluate(); branch with RuleEngine.If(). ---- - -# CSharpEssentials.Rules - -Composable rule engine for .NET. Define business logic as small rules in any style and combine them freely. - -## Installation - -```bash -dotnet add package CSharpEssentials.Rules -``` - -## Namespaces - -```csharp -using CSharpEssentials.Rules; // IRule, RuleEngine -using CSharpEssentials.ResultPattern; // Result, Result -using CSharpEssentials.Errors; // Error -``` - ---- - -## Three Definition Styles - -All styles are interchangeable — mix and match freely when composing. - -### 1. Class (injectable, unit-testable) - -```csharp -public sealed class AgeRule : IRule -{ - public Result Evaluate(UserContext ctx, CancellationToken ct = default) => - ctx.Age >= 18 ? Result.Success() : Error.Validation("Age.Underage", "Must be at least 18."); -} - -// With constructor injection -public sealed class LicenseRule : IRule -{ - private readonly ILicenseRepository _repo; - public LicenseRule(ILicenseRepository repo) => _repo = repo; - - public Result Evaluate(UserContext ctx, CancellationToken ct = default) => - _repo.IsValid(ctx.LicenseId) ? Result.Success() : Error.Validation("License.Invalid", "License not found."); -} -``` - -### 2. Func field (reusable, no class needed) - -```csharp -Func regionRule = ctx => - ctx.IsAllowedRegion ? Result.Success() : Error.Forbidden("Region.Blocked", "Not available in your region."); - -static Result CheckEmail(UserContext ctx) => - ctx.Email.Contains('@') ? Result.Success() : Error.Validation("Email.Invalid", "Invalid email."); -``` - -### 3. Inline lambda (one-off, maximum density) - -```csharp -Result r = RuleEngine.Evaluate( - (UserContext ctx) => ctx.Age >= 18 ? Result.Success() : Error.Validation("Age.Underage", "Must be 18+."), - userCtx); -``` - ---- - -## Evaluating a Single Rule - -```csharp -// Any style passes directly — no .ToRule() needed -Result r1 = RuleEngine.Evaluate(new AgeRule(), ctx); -Result r2 = RuleEngine.Evaluate(regionRule, ctx); // Func variable -Result r3 = RuleEngine.Evaluate(CheckEmail, ctx); // method group -Result r4 = RuleEngine.Evaluate( - (UserContext c) => c.HasLicense ? Result.Success() : Error.Validation("License.Missing", "Required."), - ctx); -``` - ---- - -## Combining Rules - -Compose first using extension methods on arrays, then evaluate with `RuleEngine.Evaluate`. - -### And — all must pass (collects all failures) - -```csharp -// Class instances -Result andResult = RuleEngine.Evaluate( - new IRuleBase[] { new AgeRule(), new LicenseRule(repo) }.And(), - ctx); - -// Func array — no .ToRule() needed -Result andResult2 = RuleEngine.Evaluate( - new Func[] { regionRule, CheckEmail }.And(), - ctx); - -// Mixed: class + lambda -Result andResult3 = RuleEngine.Evaluate( - new IRuleBase[] - { - new AgeRule(), - regionRule.ToRule(), - ((Func)(c => c.HasLicense ? Result.Success() : Error.Validation("License.Missing", "Required."))).ToRule() - }.And(), - ctx); -``` - -### Or — at least one must pass - -```csharp -Result orResult = RuleEngine.Evaluate( - new Func[] { regionRule, CheckEmail }.Or(), - ctx); -``` - -### Linear — stop on first failure - -```csharp -// Class instances -Result linear = RuleEngine.Evaluate( - new IRule[] { new AgeRule(), new LicenseRule(repo) }.Linear(), - ctx); - -// Func chaining with .Next() — reads like a pipeline -Result pipeline = RuleEngine.Evaluate( - ((Func)CheckEmail) - .Next(regionRule) - .Next(c => c.Age >= 18 ? Result.Success() : Error.Validation("Age.Underage", "Must be 18+")), - ctx); -``` - -### Conditional — if/then/else branching - -```csharp -// Rule as condition -Result conditional = RuleEngine.If( - new AgeRule(), - success: new GrantAccessRule(), - failure: new DenyAccessRule(), - ctx); - -// Lambda branches -Result conditional2 = RuleEngine.If( - (UserContext c) => c.Age >= 18 ? Result.Success() : Error.Validation("Age.Underage", "Must be 18+"), - success: c => Result.Success(), - failure: c => Error.Forbidden("Access.Denied", "Access denied."), - ctx); - -// Bool shorthand -Result conditional3 = RuleEngine.If( - condition: ctx.IsAllowedRegion, - success: new GrantAccessRule(), - failure: new DenyAccessRule(), - ctx); -``` - ---- - -## Rules with Values (Result) - -```csharp -// Class form -public sealed class GradeRule : IRule -{ - public Result Evaluate(int score, CancellationToken ct = default) - { - if (score >= 90) return "A"; - if (score >= 80) return "B"; - return Error.Validation("Grade.Failed", "Score too low."); - } -} - -// Inline form — no class needed -Result grade = RuleEngine.Evaluate( - (int score) => score >= 90 ? Result.Success("A") : Error.Validation("Grade.Failed", "Score too low."), - 85); -``` - ---- - -## Domain Error Hierarchies - -```csharp -public static class RegistrationErrors -{ - public static readonly Error Underage = - Error.Validation("Registration.Underage", "Applicant must be at least 18."); - public static readonly Error NoLicense = - Error.Validation("Registration.NoLicense", "A valid driver's license is required."); - public static readonly Error RegionBlocked = - Error.Forbidden("Registration.RegionBlocked", "Registration is not available in your region."); -} - -// Func rules referencing domain error catalogue -Func regionRule = - ctx => ctx.IsAllowedRegion ? Result.Success() : RegistrationErrors.RegionBlocked; - -// Compose all rules — collects all failures -Result result = RuleEngine.Evaluate( - new Func[] - { - c => c.Age >= 18 ? Result.Success() : RegistrationErrors.Underage, - c => c.HasLicense ? Result.Success() : RegistrationErrors.NoLicense, - regionRule - }.And(), - applicant); - -result.Match( - onSuccess: () => Console.WriteLine("Approved"), - onError: errors => - { - foreach (Error e in errors) - Console.WriteLine($"[{e.Type}] {e.Code}: {e.Description}"); - }); -``` - ---- - -## Best Practices - -- `array.And()` collects **all** failures; `array.Linear()` stops at the **first** failure -- Prefer `.Next()` for readable linear pipelines over `.Linear()` with an array -- No explicit `.ToRule()` needed when passing `Func<>` to `RuleEngine.Evaluate` or to `.And()/.Or()` on `Func[]` -- Group domain errors in static classes — rules read like domain language -- Test each `IRule` in isolation: `Evaluate(context)` → assert Result — no mocking needed -- Use class rules when the rule needs DI; use `Func` fields when it doesn't diff --git a/.agents/skills/csharpessentials-time/SKILL.md b/.agents/skills/csharpessentials-time/SKILL.md deleted file mode 100644 index fde9e81..0000000 --- a/.agents/skills/csharpessentials-time/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -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. ---- - -# CSharpEssentials.Time - -Testable time abstraction built on .NET's `TimeProvider`. Never call `DateTime.UtcNow` directly in domain or service code. - -## Installation - -```bash -dotnet add package CSharpEssentials.Time -``` - -## Namespace - -```csharp -using CSharpEssentials.Time; -``` - ---- - -## IDateTimeProvider - -```csharp -public interface IDateTimeProvider -{ - TimeZoneInfo TimeZone { get; } // TimeZoneInfo.Local - TimeZoneInfo TimeZoneUtc { get; } // TimeZoneInfo.Utc - - DateTime UtcNowDateTime { get; } - DateTimeOffset UtcNow { get; } - - // NET6+ only: - DateOnly UtcNowDate { get; } - TimeOnly UtcNowTime { get; } -} -``` - ---- - -## Register in DI - -```csharp -// Program.cs -builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddSingleton(); -``` - ---- - -## Use in Services - -```csharp -public class OrderService -{ - private readonly IDateTimeProvider _time; - - public OrderService(IDateTimeProvider time) => _time = time; - - public Order Create(Cart cart) => new Order - { - CreatedAt = _time.UtcNow, - DueDate = _time.UtcNowDate.AddDays(7) // DateOnly — NET6+ - }; -} -``` - ---- - -## Test with FakeTimeProvider - -```csharp -// Install: dotnet add package Microsoft.Extensions.TimeProvider.Testing -using Microsoft.Extensions.Time.Testing; - -var fake = new FakeTimeProvider(); -fake.SetUtcNow(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)); - -var provider = new DateTimeProvider(fake); -var svc = new OrderService(provider); - -var order = svc.Create(cart); -Assert.Equal(new DateOnly(2025, 1, 15), order.DueDate.AddDays(-7)); - -// Advance the clock -fake.Advance(TimeSpan.FromHours(2)); -Assert.Equal(new TimeOnly(12, 0, 0), provider.UtcNowTime); -``` - ---- - -## DateTime Extensions (NET6+) - -```csharp -DateTime dt = DateTime.UtcNow; - -DateOnly date = dt.ToDateOnly(); // DateOnly.FromDateTime(dt) -TimeOnly time = dt.ToTimeOnly(); // TimeOnly.FromDateTime(dt) -``` - ---- - -## Best Practices - -- Inject `IDateTimeProvider` — never call `DateTime.UtcNow` directly in domain/service code -- `TimeProvider.System` is the production singleton — register once, reuse everywhere -- `DateOnly` / `TimeOnly` properties are `#if NET6_0_OR_GREATER` — guard usage in `netstandard2.x` targets -- `FakeTimeProvider.Advance()` simulates elapsed time without `Thread.Sleep` in tests diff --git a/.agents/skills/csharpessentials-validation/SKILL.md b/.well-known/agent-skills/csharpessentials-validation/SKILL.md similarity index 100% rename from .agents/skills/csharpessentials-validation/SKILL.md rename to .well-known/agent-skills/csharpessentials-validation/SKILL.md diff --git a/.well-known/agent-skills/index.json b/.well-known/agent-skills/index.json index 729976f..f878877 100644 --- a/.well-known/agent-skills/index.json +++ b/.well-known/agent-skills/index.json @@ -72,7 +72,7 @@ }, { "name": "csharpessentials-meta", - "description": "Use when deciding which CSharpEssentials package to use — overview of all 18 packages organized by concern, the meta-package that bundles core functional modules, and a quick-reference table mapping problems to packages.", + "description": "Use when deciding which CSharpEssentials package to use — overview of all 19 packages organized by concern, the meta-package that bundles core functional modules, and a quick-reference table mapping problems to packages.", "files": ["SKILL.md"] }, { @@ -89,6 +89,11 @@ "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.", "files": ["SKILL.md"] + }, + { + "name": "csharpessentials-validation", + "description": "Use when writing model validation with Result integration — Validator base class, rules.For().NotEmpty()/.MaxLength()/.GreaterThan() chains, SetValidator for nested objects, ForEach for collections, native C# if/switch for conditional rules. Also use when migrating FROM FluentValidation — this skill contains a full side-by-side migration guide.", + "files": ["SKILL.md"] } ] } diff --git a/AGENTS.md b/AGENTS.md index d57bbae..9bae92d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## What -CSharpEssentials is a modular .NET NuGet ecosystem (14+ packages) that bridges OOP and Functional Programming in C#. Core patterns: Result/Maybe monads, Discriminated Unions (Any), composable Rules engine, DDD base classes (EntityBase), EF Core interceptors/pagination, and ASP.NET Core utilities. Multi-targets: .NET 9/8, netstandard2.1/2.0. Current version: 3.0.0. +CSharpEssentials is a modular .NET NuGet ecosystem (19 packages) that bridges OOP and Functional Programming in C#. Core patterns: Result/Maybe monads, Discriminated Unions (Any), composable Rules engine, DDD base classes (EntityBase), EF Core interceptors/pagination, and ASP.NET Core utilities. Multi-targets: .NET 9/8, netstandard2.1/2.0. Current version: 3.0.0. ## Why @@ -18,6 +18,7 @@ CSharpEssentials is a modular .NET NuGet ecosystem (14+ packages) that bridges O - Test: `dotnet test` - Pack: `dotnet pack` - Publish: `./build-and-publish-nugets.sh` +- First-time setup: `git config core.hooksPath .githooks` (activates pre-commit badge validation) - Naming: PascalCase types, camelCase locals, `_camelCase` private fields - File layout: One public type per file, filename matches type name - Tests live in `CSharpEssentials.Tests/` diff --git a/CLAUDE.md b/CLAUDE.md index 4693c4f..bf666c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,38 +1,10 @@ -## What +# CSharpEssentials — Claude Instructions -CSharpEssentials is a modular .NET NuGet ecosystem (14+ packages) that bridges OOP and Functional Programming in C#. Core patterns: Result/Maybe monads, Discriminated Unions (Any), composable Rules engine, DDD base classes (EntityBase), EF Core interceptors/pagination, and ASP.NET Core utilities. Multi-targets: .NET 9/8, netstandard2.1/2.0. Current version: 3.0.0. +> All project instructions are in [AGENTS.md](AGENTS.md). Claude Code reads both files; this file exists so tools that look for CLAUDE.md find their way to the canonical source. -## Why - -- **Nullable + TreatWarningsAsErrors**: Prevents null reference bugs at compile time; every package must be null-safe by design. -- **Modular packages**: Users take only what they need; CSharpEssentials meta-package bundles core functional modules. -- **SonarAnalyzer.CSharp**: Static analysis in every build via Directory.Build.props — consistent quality across all packages without per-project config. -- **Central Package Management (Directory.Packages.props)**: Single version source-of-truth; prevents version drift across packages. -- **No abstract layers for the sake of it**: Every abstraction (IDateTimeProvider, IDomainEventPublisher) exists to enable testability or infrastructure-swapping, not ceremony. - -## How - -- Build: `dotnet build` -- Test: `dotnet test` -- Pack: `dotnet pack` -- Publish: `./build-and-publish-nugets.sh` -- First-time setup: `git config core.hooksPath .githooks` (activates pre-commit badge validation) -- Naming: PascalCase types, camelCase locals, `_camelCase` private fields -- File layout: One public type per file, filename matches type name -- Tests live in `CSharpEssentials.Tests/` - -## Don't - -- Don't use `dynamic` type — defeats the purpose of the type-safe libraries. -- Don't suppress warnings with `#pragma warning disable` — fix the root cause. -- Don't add `// TODO` to committed code — either implement it or track it as an issue. -- Don't add new packages to `Directory.Packages.props` without checking existing entries. -- Don't break multi-targeting — test against all declared target frameworks. -- Don't add docstrings or comments unless explicitly asked. -- Don't create placeholder/stub implementations. - -## Boundaries - -- **Always**: Write tests, follow nullable annotations, use conventional commits, run build before committing. -- **Ask first**: Adding new NuGet packages, changing shared abstractions (interfaces in .Core/.Entity), bumping major version, removing public API. -- **Never**: Commit secrets, edit `.snupkg`/`.nupkg` artifacts, push directly to main, suppress TreatWarningsAsErrors. +See [AGENTS.md](AGENTS.md) for: +- What the project is and why it's built this way +- Build, test, pack, and publish commands +- Naming conventions and file layout +- What not to do +- Contribution boundaries diff --git a/CSharpEssentials.Any/Readme.MD b/CSharpEssentials.Any/Readme.MD index f359466..c5dd980 100644 --- a/CSharpEssentials.Any/Readme.MD +++ b/CSharpEssentials.Any/Readme.MD @@ -69,3 +69,20 @@ response.Switch( second: i => Console.WriteLine($"NotFound: {i}"), third: e => Console.WriteLine($"Error: {e.Message}")); ``` + +### Collection Extensions + +Scatter a sequence of unions into typed arrays in one pass: + +```csharp +// Classify API responses into successes and errors +var (users, errors) = responses + .Traverse(r => ClassifyResponse(r)); // returns Any + +// Or partition an existing sequence of unions +var (drafts, published) = articles + .Select(a => GetState(a)) // returns Any + .Partition(); +``` + +`Partition` and `Traverse` are available for all arities (`Any` through `Any`), returning a tuple with one typed array per variant. diff --git a/CSharpEssentials.Maybe/Readme.MD b/CSharpEssentials.Maybe/Readme.MD index d5ebc13..788b542 100644 --- a/CSharpEssentials.Maybe/Readme.MD +++ b/CSharpEssentials.Maybe/Readme.MD @@ -79,3 +79,22 @@ Maybe order = await FindUserAsync(1) .BindAsync(user => GetOrderAsync(user.Id)) .MapAsync(order => EnrichAsync(order)); ``` + +### Collection Extensions + +```csharp +// Require ALL lookups to succeed — None if any is missing +Maybe allUsers = userIds.Traverse(id => FindUser(id)); + +// Collect present values and count absences +var (values, missingCount) = maybes.Partition(); + +// Sequence: None if any element is None +Maybe configs = configMaybes.Sequence(); +``` + +| Method | Returns | Behavior | +|--------|---------|----------| +| `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 | diff --git a/CSharpEssentials.Results/Readme.MD b/CSharpEssentials.Results/Readme.MD index 7591664..d996032 100644 --- a/CSharpEssentials.Results/Readme.MD +++ b/CSharpEssentials.Results/Readme.MD @@ -118,3 +118,31 @@ Result result = await FetchUserAsync(id) .ThenAsync(user => EnrichAsync(user)) .EnsureAsync(v => IsValidAsync(v), Error.Validation("Invalid", "Not valid.")); ``` + +### Collection Extensions + +Batch operations on sequences of results: + +```csharp +// Collect ALL errors from multiple results +Result validation = new[] { ValidateName(input), ValidateEmail(input), ValidateAge(input) } + .CombineAll(); + +// Map each item and collect — success array or all errors +Result orders = orderIds.Traverse(id => GetOrder(id)); + +// Split into successes and errors without short-circuiting +var (succeeded, failed) = results.Partition(); + +// Stop at first failure +Result pipeline = new[] { CheckStock(), ReserveItem(), CreateOrder() } + .FirstFailureOrSuccesses(); +``` + +| Method | Strategy | Returns | +|--------|----------|---------| +| `CombineAll()` | Collect all errors | `Result` or `Result` | +| `Sequence()` | Collect all | `Result` with all values or all errors | +| `Traverse(selector)` | Map then sequence | `Result` | +| `Partition()` | Split | `(T[] Successes, Error[] Errors)` | +| `FirstFailureOrSuccesses()` | Short-circuit | `Result` or `Result` | diff --git a/CSharpEssentials.Validation/Readme.MD b/CSharpEssentials.Validation/Readme.MD index bd05f7e..07e18c2 100644 --- a/CSharpEssentials.Validation/Readme.MD +++ b/CSharpEssentials.Validation/Readme.MD @@ -465,6 +465,36 @@ The trade-off: a new `RuleContext` is created per `ValidateAsync` call. This - Expression trees limit Native AOT compatibility - `When()` blocks create implicit coupling between rule registration and condition evaluation +## Railway Integration + +`ValidateWith` / `ValidateWithAsync` plug validators directly into `Result` pipelines. If the result is already a failure, the validator is skipped entirely. + +```csharp +// Named validator in a pipeline +Result result = await ParseCommand(input) + .ValidateWithAsync(new CreateUserCommandValidator(), ct); + +// Inline validation — no dedicated class +Result result = await ParseCommand(input) + .ValidateWithAsync(command, (m, rules) => + { + rules.For(() => m.Email).NotEmpty().EmailAddress(); + rules.For(() => m.Name).NotEmpty().MaxLength(100); + }); + +// Works on Task> and ValueTask> +Result order = await GetOrderAsync(id) + .ValidateWithAsync(new OrderValidator(), ct); +``` + +| Overload | Source type | +|----------|-------------| +| `result.ValidateWith(configure)` | `Result` → `Result` (sync) | +| `result.ValidateWithAsync(validator, ct)` | `Result` → `ValueTask>` | +| `result.ValidateWithAsync(configure)` | `Result` → `ValueTask>` | +| `resultTask.ValidateWithAsync(validator, ct)` | `Task>` → `ValueTask>` | +| `resultValueTask.ValidateWithAsync(validator, ct)` | `ValueTask>` → `ValueTask>` | + ## Dependencies - `CSharpEssentials.Results` diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c1cefb9..960b9bd 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -252,6 +252,36 @@ Result result = await GetUserAsync(id) .Tap(_ => _logger.LogInformation("User retrieved")); ``` +### Collection Extensions + +Batch operations on sequences of results — without manually looping. + +| Method | Strategy | What It Does | +|--------|----------|-------------| +| `CombineAll(IEnumerable)` | Collect all errors | Success if all succeed; accumulates ALL errors if any fail | +| `CombineAll(IEnumerable>)` | Collect all errors | Same as `Sequence` — success array or all errors | +| `Sequence(IEnumerable>)` | Collect all | Returns `Result` with all values, or all errors | +| `Traverse(source, selector)` | Map + sequence | Applies selector to each element, then sequences | +| `Partition(IEnumerable>)` | Split | Returns `(T[] Successes, Error[] Errors)` — never fails | +| `FirstFailureOrSuccesses(IEnumerable)` | Short-circuit | Returns first failure immediately; otherwise success | +| `FirstFailureOrSuccesses(IEnumerable>)` | Short-circuit | Returns first failure or `Result` of all values | + +```csharp +// Validate a batch — collect ALL errors +Result validation = validationResults.CombineAll(); + +// Map each item and collect all successes, or all errors +Result orders = orderIds + .Traverse(id => GetOrder(id)); + +// Split a mixed batch without short-circuiting +var (succeeded, failed) = results.Partition(); +Console.WriteLine($"{succeeded.Length} succeeded, {failed.Length} errors"); + +// Stop at first failure — useful for sequential pipeline steps +Result pipeline = steps.FirstFailureOrSuccesses(); +``` + --- ## 3. CSharpEssentials.Maybe — Explicit Optionals @@ -349,6 +379,23 @@ Result result = FindUser(id) // returns Maybe .ToMaybeResult(Error.NotFound("User.NotFound", "User does not exist")); ``` +### Collection Extensions + +| Method | What It Does | +|--------|-------------| +| `Sequence(IEnumerable>)` | `Maybe` — `None` if any element is `None` | +| `Traverse(source, selector)` | Applies selector then sequences — `None` if any is `None` | +| `Partition(IEnumerable>)` | Returns `(T[] Values, int NoneCount)` — never returns `None` | + +```csharp +// Require ALL lookups to succeed +Maybe allUsers = userIds + .Traverse(id => _cache.TryFind(id)); // None if any id is missing + +// Collect present values, count absences +var (values, missingCount) = maybes.Partition(); +``` + --- ## 4. CSharpEssentials.Any — Discriminated Unions @@ -403,6 +450,24 @@ articleState.Switch( ); ``` +### Collection Extensions + +Scatter a sequence of unions into per-type arrays. Works for all arities (`Any` through `Any`). + +| Method | What It Does | +|--------|-------------| +| `Partition(IEnumerable>)` | Returns `(T0[] First, T1[] Second)` | +| `Traverse(source, selector)` | Applies selector then partitions | +| *(up to 8-arity)* | `Partition` and `Traverse` overloads for `Any` | + +```csharp +// Classify API responses into successes and errors in one pass +var (users, errors) = responses + .Traverse(r => ClassifyResponse(r)); // returns Any + +Console.WriteLine($"{users.Length} succeeded, {errors.Length} failed"); +``` + --- ## 5. CSharpEssentials.Core — Utility Belt @@ -1070,6 +1135,39 @@ services.AddMediatorBehaviors(); Validation runs before the handler. On failure the handler is never invoked. `Result` / `Result` handlers receive `Result.Failure` directly; all other handler return types trigger `EnhancedValidationException` (caught by `GlobalExceptionHandler`). Non-cancellation exceptions thrown by a validator are caught and converted to `Error.Exception("Validator.Exception", ex)` so validator bugs never rethrow through the pipeline. +### Railway Validation Bindings + +`ValidateWith` / `ValidateWithAsync` plug validators directly into a `Result` railway. If the result is already a failure, the validator is skipped entirely. + +| Method | Input | Returns | When to Use | +|--------|-------|---------|-------------| +| `result.ValidateWith(configure)` | `Result` | `Result` | Inline sync validation in a pipeline | +| `result.ValidateWithAsync(validator, ct)` | `Result` | `ValueTask>` | Named validator in a pipeline | +| `result.ValidateWithAsync(configure)` | `Result` | `ValueTask>` | Inline sync delegate, async context | +| `result.ValidateWithAsync(asyncConfigure, ct)` | `Result` | `ValueTask>` | Inline async delegate | +| `taskResult.ValidateWithAsync(validator, ct)` | `Task>` | `ValueTask>` | Awaited task pipeline | +| `valueTaskResult.ValidateWithAsync(validator, ct)` | `ValueTask>` | `ValueTask>` | ValueTask pipeline | + +```csharp +// Named validator — plugs straight into a Result chain +Result result = await ParseCommand(input) + .ValidateWithAsync(new CreateUserCommandValidator(), ct); + +// Inline validation — no dedicated class needed +Result result = await ParseCommand(input) + .ValidateWithAsync(command, (m, rules) => + { + rules.For(() => m.Email).NotEmpty().EmailAddress(); + rules.For(() => m.Name).NotEmpty().MaxLength(100); + }); + +// Works on Task> — no intermediate await +Result order = await GetOrderAsync(id) // Task> + .ValidateWithAsync(new OrderValidator(), ct); // skips if already failed +``` + +Short-circuits immediately: if `result.IsFailure` before validation runs, the existing errors pass through and the validator is never invoked. This makes it safe to chain multiple `ValidateWithAsync` calls without nested null/failure checks. + --- ## Ecosystem Design Patterns diff --git a/scripts/analyze_example_coverage.py b/scripts/analyze_example_coverage.py index 874f6c1..06182c2 100644 --- a/scripts/analyze_example_coverage.py +++ b/scripts/analyze_example_coverage.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """ CSharpEssentials - Public API Example Coverage Analyzer -Her kütüphanedeki public API'leri (tip, metod, property) çıkarıp -ilgili example projesinde kullanılıp kullanılmadığını kontrol eder. + +Extracts public APIs (types, methods, properties) from each library +and checks whether they are used in the corresponding example project. """ import os @@ -10,9 +11,9 @@ from pathlib import Path from collections import defaultdict -PROJECT_ROOT = Path("/Users/recepsen/Documents/projects/recep/CSharpEssentials") +PROJECT_ROOT = Path(__file__).parent.parent -# Kütüphane -> Example klasörü eşleştirmesi +# Library -> Example directory mapping LIB_EXAMPLE_MAP = { "CSharpEssentials.Any": "Examples.Any", "CSharpEssentials.AspNetCore": "Examples.AspNetCore", @@ -29,15 +30,16 @@ "CSharpEssentials.Results": "Examples.Results", "CSharpEssentials.Rules": "Examples.Rules", "CSharpEssentials.Time": "Examples.Time", + "CSharpEssentials.Validation": "Examples.Validation", "CSharpEssentials": "Examples.Main", } def get_cs_files(directory): - """Dizindeki tüm .cs dosyalarını döndürür (bin/obj hariç).""" + """Returns all .cs files in the directory (excluding bin/obj).""" files = [] for root, dirs, filenames in os.walk(directory): - # bin/obj klasörlerini atla + # Skip bin/obj directories dirs[:] = [d for d in dirs if d not in ('bin', 'obj')] for f in filenames: if f.endswith('.cs'): @@ -47,9 +49,9 @@ def get_cs_files(directory): def extract_types_and_members(file_path): """ - Bir C# dosyasından public tip ve üye isimlerini çıkarır. - Dönüş: [(kategori, isim, dosya_ismi), ...] - kategori: 'type' | 'method' | 'property' | 'field' | 'enum_value' + Extracts public type and member names from a C# file. + Returns: [(category, name, filename), ...] + category: 'type' | 'method' | 'property' | 'field' | 'enum_value' """ try: with open(file_path, 'r', encoding='utf-8') as f: @@ -60,7 +62,7 @@ def extract_types_and_members(file_path): results = [] lines = content.split('\n') - # Dosyadaki tip isimlerini bul (constructor tespiti için) + # Find type names in file (for constructor detection) type_names = set() type_pattern = re.compile( r'^\s*public\s+(?:static\s+|abstract\s+|sealed\s+|readonly\s+|partial\s+)*' @@ -71,7 +73,7 @@ def extract_types_and_members(file_path): if m: type_names.add(m.group(1)) - # Satır satır parse et + # Parse line by line for line in lines: stripped = line.strip() if not stripped.startswith('public '): @@ -79,25 +81,25 @@ def extract_types_and_members(file_path): if stripped.startswith('public override string ToString'): continue - # --- Enum değerleri --- - # public const X veya sadece public enum member'ları - # Basitçe: public ile başlayan ve = veya , içeren satırlar + # --- Enum values --- + # public const X or plain public enum members + # Simplified: lines starting with public containing = or , enum_val_match = re.match(r'^\s*public\s+const\s+\w+\s+(\w+)\s*[=;]', stripped) if enum_val_match: results.append(('field', enum_val_match.group(1), os.path.basename(file_path))) continue - # --- Tip tanımları --- + # --- Type definitions --- type_match = type_pattern.match(stripped) if type_match: results.append(('type', type_match.group(1), os.path.basename(file_path))) continue - # --- Metodlar --- + # --- Methods --- # public [static|async|override|virtual|readonly|partial|implicit|explicit] Name() - # Ama constructor hariç: Name == tip_ismi ve parantez varsa + # Exclude constructors: Name == type_name with parentheses if ' operator ' in stripped: - continue # implicit/explicit operator'leri atla + continue # Skip implicit/explicit operators method_match = re.match( r'^\s*public\s+(?:static\s+|async\s+|override\s+|virtual\s+|abstract\s+|readonly\s+|partial\s+)*' r'[\w<>,\[\]\s>?]+?\s+(\w+)(?:<[^>]+>)?\s*\(', @@ -105,19 +107,19 @@ def extract_types_and_members(file_path): ) if method_match: name = method_match.group(1) - # Constructor filtrele + # Filter out constructors if name in type_names: continue - # Object metodlarını atla + # Skip Object base methods if name in ('ToString', 'Equals', 'GetHashCode', 'CompareTo', 'Clone'): continue - # Genel C# keyword'lerini atla + # Skip C# keywords if name in ('if', 'while', 'for', 'switch', 'using', 'return', 'new', 'await'): continue results.append(('method', name, os.path.basename(file_path))) continue - # --- Property'ler --- + # --- Properties --- # public [static|override|...] Name { get; set; } prop_match = re.match( r'^\s*public\s+(?:static\s+|override\s+|virtual\s+|abstract\s+|readonly\s+)*' @@ -126,13 +128,13 @@ def extract_types_and_members(file_path): ) if prop_match: name = prop_match.group(1) - # Indexer atla + # Skip indexers if name == 'this': continue results.append(('property', name, os.path.basename(file_path))) continue - # --- Field'ler --- + # --- Fields --- # public [static|readonly] Name = ...; field_match = re.match( r'^\s*public\s+(?:static\s+|readonly\s+)*[\w<>,\[\]\s>?]+?\s+(\w+)\s*[=;]', @@ -147,7 +149,7 @@ def extract_types_and_members(file_path): def read_example_content(example_dir): - """Example klasöründeki tüm .cs dosyalarının içeriğini birleştirir.""" + """Concatenates the contents of all .cs files in the example directory.""" if not os.path.exists(example_dir): return "" content_parts = [] @@ -162,28 +164,28 @@ def read_example_content(example_dir): def check_usage(api_name, example_content): """ - API isminin example içeriğinde kullanılıp kullanılmadığını kontrol eder. - Tam kelime eşleşmesi arar. + Checks if the API name is used in the example content. + Searches for whole-word matches. """ pattern = r'\b' + re.escape(api_name) + r'\b' return bool(re.search(pattern, example_content)) def analyze_library(lib_name, example_name): - """Bir kütüphane için analiz yapar.""" + """Performs analysis for a library.""" lib_dir = PROJECT_ROOT / lib_name example_dir = PROJECT_ROOT / "examples" / example_name if not lib_dir.exists(): return None - # Tüm public API'leri topla - all_apis = [] # [(kategori, isim, dosya), ...] + # Collect all public APIs + all_apis = [] # [(category, name, file), ...] for cs_file in get_cs_files(lib_dir): apis = extract_types_and_members(cs_file) all_apis.extend(apis) - # Tekilleştir (aynı isim farklı dosyalarda olabilir) + # Deduplicate (same name may appear in multiple files) seen = set() unique_apis = [] for cat, name, fname in all_apis: @@ -192,11 +194,11 @@ def analyze_library(lib_name, example_name): seen.add(key) unique_apis.append((cat, name, fname)) - # Example içeriğini oku + # Read example content example_content = read_example_content(example_dir) has_example = example_content != "" - # Kullanım kontrolü + # Usage check results = [] for cat, name, fname in unique_apis: used = check_usage(name, example_content) if has_example else False @@ -216,15 +218,15 @@ def analyze_library(lib_name, example_name): def generate_markdown_report(analyses): - """Analiz sonuçlarından Markdown raporu üretir.""" + """Generates a Markdown report from analysis results.""" lines = [] - lines.append("# CSharpEssentials - Detaylı Example Coverage Raporu") + lines.append("# CSharpEssentials - Detailed Example Coverage Report") lines.append("") - lines.append("> Bu rapor, **her bir public API öğesinin** (tip, metod, property, field)") - lines.append("> ilgili example projesinde kullanılıp kullanılmadığını gösterir.") + lines.append("> This report shows whether each **public API element** (type, method, property, field)") + lines.append("> is used in the corresponding example project.") lines.append(">") - lines.append("> ✅ = Example'da kullanılıyor") - lines.append("> ❌ = Example'da kullanılmıyor") + lines.append("> ✅ = Used in example") + lines.append("> ❌ = Not used in example") lines.append("") total_apis = 0 @@ -246,16 +248,16 @@ def generate_markdown_report(analyses): lines.append(f"## {lib}") lines.append("") if not has_ex: - lines.append("⚠️ **Example projesi bulunamadı!**") + lines.append("⚠️ **Example project not found!**") lines.append("") continue - lines.append(f"**Kapsam:** {used_count}/{total_count} ({used_count*100//total_count if total_count else 0}%)") + lines.append(f"**Coverage:** {used_count}/{total_count} ({used_count*100//total_count if total_count else 0}%)") lines.append("") - lines.append("| Kategori | API İsmi | Example'da Kullanım |") - lines.append("|----------|----------|---------------------|") + lines.append("| Category | API Name | Used in Example |") + lines.append("|----------|----------|-----------------|") - # Alfabetik sırala + # Sort alphabetically for api in sorted(apis, key=lambda x: (x['category'], x['name'])): icon = "✅" if api['used'] else "❌" cat = api['category'] @@ -264,14 +266,14 @@ def generate_markdown_report(analyses): lines.append("") - # Genel özet + # Overall summary lines.append("---") lines.append("") - lines.append("## 📊 Genel Example Coverage Özeti") + lines.append("## 📊 Overall Example Coverage Summary") lines.append("") - lines.append(f"- **Toplam Public API:** {total_apis}") - lines.append(f"- **Example'da Kullanılan:** {total_used}") - lines.append(f"- **Kapsam:** {total_used*100//total_apis if total_apis else 0}%") + lines.append(f"- **Total Public APIs:** {total_apis}") + lines.append(f"- **Used in Examples:** {total_used}") + lines.append(f"- **Coverage:** {total_used*100//total_apis if total_apis else 0}%") lines.append("") return "\n".join(lines) @@ -289,7 +291,7 @@ def main(): with open(output_path, 'w', encoding='utf-8') as f: f.write(report) - print(f"\nRapor oluşturuldu: {output_path}") + print(f"Report generated: {output_path}") if __name__ == "__main__":