diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d2b260f..524c0a0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,15 @@ jobs:
- name: Test Dto
run: dotnet test packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/ZibStack.NET.Dto.Tests.csproj -c Release --no-build
+ - name: Test Result
+ run: dotnet test packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj -c Release --no-build
+
+ - name: Test Log Analyzers
+ run: dotnet test packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj -c Release --no-build
+
+ - name: Test Validation
+ run: dotnet test packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj -c Release --no-build
+
- name: Benchmark Log
run: dotnet run --project packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj -c Release -- --filter "*" --exporters json
diff --git a/ZibStack.NET.slnx b/ZibStack.NET.slnx
index f7557a5..be41c5a 100644
--- a/ZibStack.NET.slnx
+++ b/ZibStack.NET.slnx
@@ -28,9 +28,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ZibStack.NET.Dto/README.md b/packages/ZibStack.NET.Dto/README.md
index 5a8bb33..e7eb705 100644
--- a/packages/ZibStack.NET.Dto/README.md
+++ b/packages/ZibStack.NET.Dto/README.md
@@ -198,6 +198,8 @@ public IActionResult HandleCreate(ICanCreate request) where T : class
| `[PickFrom(typeof(T), ...)]` | Record (partial) | Like TS `Pick` — whitelist of properties |
| `[OmitFrom(typeof(T), ...)]` | Record (partial) | Like TS `Omit` — exclude listed properties |
| `[QueryDto]` | Class | Generates filter DTO with nullable properties + `ApplyFilter(IQueryable)` |
+| `[QueryDto(Sortable = true)]` | Class | Adds `SortBy`, `SortDirection`, `ApplySort()`, `Apply()` to query DTO |
+| `PaginatedResponse` | — | Generic paginated wrapper with `Items`, `TotalCount`, `Page`, `PageSize` |
| `[PartialFrom(typeof(T))]` | Record (partial) | Generates `PatchField` properties + `ApplyTo()` for all properties of `T` |
| `[IntersectFrom(typeof(T))]` | Record (partial) | Combine multiple types into one (like TS `&`). Apply multiple times. |
| `[DtoIgnore]` | Property | Excludes from generated DTOs |
@@ -363,6 +365,83 @@ public IActionResult List([FromQuery] ProductQuery query)
}
```
+### Sortable queries
+
+Add `Sortable = true` to get `SortBy`, `SortDirection` properties and `ApplySort(IQueryable)`:
+
+```csharp
+[QueryDto(Sortable = true, DefaultSort = "Name")]
+public class Product
+{
+ public string Name { get; set; }
+ public decimal Price { get; set; }
+ public int Stock { get; set; }
+}
+```
+
+```csharp
+// Generated
+public record ProductQuery
+{
+ public string? Name { get; init; }
+ public decimal? Price { get; init; }
+ public int? Stock { get; init; }
+ public string? SortBy { get; init; }
+ public SortDirection? SortDirection { get; init; }
+
+ public IQueryable ApplyFilter(IQueryable query) { ... }
+ public IQueryable ApplySort(IQueryable query) { ... }
+ public IQueryable Apply(IQueryable query) { ... } // filter + sort
+}
+
+// Usage
+[HttpGet]
+public IActionResult List([FromQuery] ProductQuery query)
+{
+ var results = query.Apply(_db.Products).ToList();
+ return Ok(results);
+}
+```
+
+`SortBy` is case-insensitive and matches property names. Unknown values are ignored (no sort applied). `DefaultSort` and `DefaultSortDirection` set fallback behavior when `SortBy`/`SortDirection` are not provided.
+
+## Paginated response (`PaginatedResponse`)
+
+Generic wrapper for paginated results:
+
+```csharp
+// Simple creation
+var page = PaginatedResponse.Create(items, totalCount: 100, page: 2, pageSize: 10);
+
+// From IQueryable (handles Skip/Take automatically)
+var page = await PaginatedResponse.CreateAsync(_db.Products, page: 1, pageSize: 20);
+
+// Map items (e.g. entity → response DTO)
+var response = page.Map(p => ProductResponse.FromEntity(p));
+
+// Properties
+page.Items // IReadOnlyList
+page.TotalCount // int
+page.Page // int
+page.PageSize // int
+page.TotalPages // computed
+page.HasNextPage // computed
+page.HasPreviousPage // computed
+```
+
+Full example with `[QueryDto]` + `[ResponseDto]`:
+
+```csharp
+[HttpGet]
+public async Task List([FromQuery] ProductQuery query, int page = 1, int pageSize = 20)
+{
+ var filtered = query.Apply(_db.Products);
+ var paginated = await PaginatedResponse.CreateAsync(filtered, page, pageSize);
+ var response = paginated.Map(p => ProductResponse.FromEntity(p));
+ return Ok(response);
+}
+```
+
## `ApplyWithChanges()` (Update DTOs only)
Like `ApplyTo()` but returns a tuple with the list of actually changed field names. Available on Update, Combined, and UpdateDtoFor requests:
diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs
index de067ad..a424dc8 100644
--- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs
+++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs
@@ -225,6 +225,12 @@ namespace ZibStack.NET.Dto
internal sealed class QueryDtoAttribute : System.Attribute
{
public string? Name { get; set; }
+ /// When true, adds SortBy and SortDirection properties + ApplySort(IQueryable) method.
+ public bool Sortable { get; set; }
+ /// Default sort property name (used when SortBy is null).
+ public string? DefaultSort { get; set; }
+ /// Default sort direction when SortDirection is null. Defaults to Asc.
+ public ZibStack.NET.Dto.SortDirection DefaultSortDirection { get; set; }
}
}
";
@@ -272,6 +278,84 @@ namespace ZibStack.NET.Dto
[System.AttributeUsage(System.AttributeTargets.Property, Inherited = false)]
internal sealed class FlattenAttribute : System.Attribute { }
}
+";
+
+ private const string PaginatedResponseSource = @"//
+#nullable enable
+
+namespace ZibStack.NET.Dto
+{
+ ///
+ /// Generic paginated response wrapper. Use with any ResponseDto or entity type.
+ ///
+ public record PaginatedResponse
+ {
+ public System.Collections.Generic.IReadOnlyList Items { get; init; } = System.Array.Empty();
+ public int TotalCount { get; init; }
+ public int Page { get; init; }
+ public int PageSize { get; init; }
+ public int TotalPages => PageSize > 0 ? (int)System.Math.Ceiling((double)TotalCount / PageSize) : 0;
+ public bool HasNextPage => Page < TotalPages;
+ public bool HasPreviousPage => Page > 1;
+
+ public static PaginatedResponse Create(
+ System.Collections.Generic.IReadOnlyList items,
+ int totalCount,
+ int page,
+ int pageSize)
+ {
+ return new PaginatedResponse
+ {
+ Items = items,
+ TotalCount = totalCount,
+ Page = page,
+ PageSize = pageSize,
+ };
+ }
+
+ public static async System.Threading.Tasks.Task> CreateAsync(
+ System.Linq.IQueryable query,
+ int page,
+ int pageSize,
+ System.Threading.CancellationToken cancellationToken = default)
+ {
+ var totalCount = query.Count();
+ var items = query
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+ return Create(items, totalCount, page, pageSize);
+ }
+
+ /// Maps items to a different type while preserving pagination metadata.
+ public PaginatedResponse Map(System.Func selector)
+ {
+ var mapped = new System.Collections.Generic.List(Items.Count);
+ foreach (var item in Items)
+ mapped.Add(selector(item));
+
+ return new PaginatedResponse
+ {
+ Items = mapped,
+ TotalCount = TotalCount,
+ Page = Page,
+ PageSize = PageSize,
+ };
+ }
+ }
+}
+";
+
+ private const string SortDirectionSource = @"//
+namespace ZibStack.NET.Dto
+{
+ /// Sort direction for sortable query DTOs.
+ public enum SortDirection
+ {
+ Asc,
+ Desc
+ }
+}
";
private const string DtoValidatorInterfaceSource = @"//
diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs
index 3634eba..1b8ec7b 100644
--- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs
+++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs
@@ -528,6 +528,10 @@ a.ConstructorArguments[0].Value is string from &&
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == QueryDtoAttributeFqn);
var nameArg = attr.NamedArguments.FirstOrDefault(a => a.Key == "Name").Value.Value as string;
+ var sortable = attr.NamedArguments.FirstOrDefault(a => a.Key == "Sortable").Value.Value is true;
+ var defaultSort = attr.NamedArguments.FirstOrDefault(a => a.Key == "DefaultSort").Value.Value as string;
+ var defaultSortDirectionRaw = attr.NamedArguments.FirstOrDefault(a => a.Key == "DefaultSortDirection").Value.Value;
+ var defaultSortDirection = defaultSortDirectionRaw is int d ? d : 0;
var properties = new List();
foreach (var prop in GetAllProperties(symbol))
@@ -559,7 +563,10 @@ a.ConstructorArguments[0].Value is string from &&
symbol.Name, ns,
SanitizeHintName(symbol.ToDisplayString().Replace(".", "_")),
nameArg ?? $"{symbol.Name}Query",
- properties);
+ properties,
+ sortable,
+ defaultSort,
+ defaultSortDirection);
} catch { return null; } }
private static List CollectPropertiesFromType(INamedTypeSymbol type)
@@ -926,7 +933,8 @@ private static string GetJsonName(IPropertySymbol prop)
private static readonly HashSet ValidationNamespaces = new()
{
- "System.ComponentModel.DataAnnotations"
+ "System.ComponentModel.DataAnnotations",
+ "ZibStack.NET.Validation"
};
private static List GetValidationAttributes(IPropertySymbol prop)
diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs
index 53906a4..8de8e1a 100644
--- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs
+++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs
@@ -529,6 +529,8 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info)
sb.AppendLine();
sb.AppendLine("using System;");
sb.AppendLine("using System.Linq;");
+ if (info.Sortable)
+ sb.AppendLine("using ZibStack.NET.Dto;");
sb.AppendLine();
if (info.Namespace is not null)
@@ -546,6 +548,16 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info)
sb.AppendLine($" public {prop.NullableTypeName} {prop.PropertyName} {{ get; init; }}");
}
+ // Sorting properties
+ if (info.Sortable)
+ {
+ sb.AppendLine();
+ sb.AppendLine(" /// Property name to sort by. Case-insensitive.");
+ sb.AppendLine(" public string? SortBy { get; init; }");
+ sb.AppendLine(" /// Sort direction. Defaults to Asc.");
+ sb.AppendLine(" public SortDirection? SortDirection { get; init; }");
+ }
+
sb.AppendLine();
// ApplyFilter
@@ -562,6 +574,42 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info)
sb.AppendLine(" return query;");
sb.AppendLine(" }");
+ // ApplySort
+ if (info.Sortable)
+ {
+ sb.AppendLine();
+ sb.AppendLine($" public IQueryable<{info.ClassName}> ApplySort(IQueryable<{info.ClassName}> query)");
+ sb.AppendLine(" {");
+
+ var defaultSortStr = info.DefaultSort is not null ? $"\"{info.DefaultSort}\"" : "null";
+ var defaultDirStr = info.DefaultSortDirection == 1 ? "SortDirection.Desc" : "SortDirection.Asc";
+ sb.AppendLine($" var sortBy = SortBy ?? {defaultSortStr};");
+ sb.AppendLine($" var direction = SortDirection ?? {defaultDirStr};");
+ sb.AppendLine();
+ sb.AppendLine(" if (sortBy is null) return query;");
+ sb.AppendLine();
+ sb.AppendLine(" return (sortBy.ToLowerInvariant(), direction) switch");
+ sb.AppendLine(" {");
+ foreach (var prop in info.Properties)
+ {
+ var lower = prop.PropertyName.ToLowerInvariant();
+ sb.AppendLine($" (\"{lower}\", ZibStack.NET.Dto.SortDirection.Asc) => query.OrderBy(x => x.{prop.PropertyName}),");
+ sb.AppendLine($" (\"{lower}\", ZibStack.NET.Dto.SortDirection.Desc) => query.OrderByDescending(x => x.{prop.PropertyName}),");
+ }
+ sb.AppendLine(" _ => query,");
+ sb.AppendLine(" };");
+ sb.AppendLine(" }");
+
+ // Convenience: ApplyFilterAndSort
+ sb.AppendLine();
+ sb.AppendLine($" public IQueryable<{info.ClassName}> Apply(IQueryable<{info.ClassName}> query)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" query = ApplyFilter(query);");
+ sb.AppendLine(" query = ApplySort(query);");
+ sb.AppendLine(" return query;");
+ sb.AppendLine(" }");
+ }
+
sb.AppendLine("}");
return sb.ToString();
}
diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs
index 76af374..c9f76ea 100644
--- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs
+++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs
@@ -49,6 +49,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
ctx.AddSource("OmitFromAttribute.g.cs", OmitFromAttributeSource);
ctx.AddSource("QueryDtoAttribute.g.cs", QueryDtoAttributeSource);
ctx.AddSource("ResponseIgnoreAttribute.g.cs", ResponseIgnoreAttributeSource);
+ ctx.AddSource("PaginatedResponse.g.cs", PaginatedResponseSource);
+ ctx.AddSource("SortDirection.g.cs", SortDirectionSource);
ctx.AddSource("IDtoValidator.g.cs", DtoValidatorInterfaceSource);
ctx.AddSource("CreateDtoForAttribute.g.cs", CreateDtoForAttributeSource);
ctx.AddSource("UpdateDtoForAttribute.g.cs", UpdateDtoForAttributeSource);
diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs
index 44603ab..cbe84dd 100644
--- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs
+++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs
@@ -27,13 +27,19 @@ internal sealed class QueryDtoInfo
public string FullyQualifiedName { get; }
public string QueryName { get; }
public List Properties { get; }
+ public bool Sortable { get; }
+ public string? DefaultSort { get; }
+ public int DefaultSortDirection { get; } // 0 = Asc, 1 = Desc
- public QueryDtoInfo(string className, string? ns, string fullyQualifiedName, string queryName, List properties)
+ public QueryDtoInfo(string className, string? ns, string fullyQualifiedName, string queryName, List properties, bool sortable = false, string? defaultSort = null, int defaultSortDirection = 0)
{
ClassName = className;
Namespace = ns;
FullyQualifiedName = fullyQualifiedName;
QueryName = queryName;
Properties = properties;
+ Sortable = sortable;
+ DefaultSort = defaultSort;
+ DefaultSortDirection = defaultSortDirection;
}
}
diff --git a/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs
new file mode 100644
index 0000000..39df7ae
--- /dev/null
+++ b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs
@@ -0,0 +1,113 @@
+using ZibStack.NET.Dto;
+
+namespace ZibStack.NET.Dto.Tests;
+
+public class PaginatedResponseTests
+{
+ [Fact]
+ public void Create_SetsAllProperties()
+ {
+ var items = new[] { "a", "b", "c" };
+ var page = PaginatedResponse.Create(items, totalCount: 10, page: 2, pageSize: 3);
+
+ Assert.Equal(items, page.Items);
+ Assert.Equal(10, page.TotalCount);
+ Assert.Equal(2, page.Page);
+ Assert.Equal(3, page.PageSize);
+ }
+
+ [Fact]
+ public void TotalPages_CalculatesCorrectly()
+ {
+ var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 10, page: 1, pageSize: 3);
+
+ Assert.Equal(4, page.TotalPages); // ceil(10/3) = 4
+ }
+
+ [Fact]
+ public void TotalPages_ExactDivision()
+ {
+ var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 9, page: 1, pageSize: 3);
+
+ Assert.Equal(3, page.TotalPages);
+ }
+
+ [Fact]
+ public void TotalPages_ZeroPageSize_ReturnsZero()
+ {
+ var page = PaginatedResponse.Create(Array.Empty(), totalCount: 0, page: 1, pageSize: 0);
+
+ Assert.Equal(0, page.TotalPages);
+ }
+
+ [Fact]
+ public void HasNextPage_True_WhenNotLastPage()
+ {
+ var page = PaginatedResponse.Create(new[] { 1, 2, 3 }, totalCount: 10, page: 2, pageSize: 3);
+
+ Assert.True(page.HasNextPage); // page 2 of 4
+ }
+
+ [Fact]
+ public void HasNextPage_False_WhenLastPage()
+ {
+ var page = PaginatedResponse.Create(new[] { 10 }, totalCount: 10, page: 4, pageSize: 3);
+
+ Assert.False(page.HasNextPage); // page 4 of 4
+ }
+
+ [Fact]
+ public void HasPreviousPage_True_WhenNotFirstPage()
+ {
+ var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 10, page: 2, pageSize: 3);
+
+ Assert.True(page.HasPreviousPage);
+ }
+
+ [Fact]
+ public void HasPreviousPage_False_WhenFirstPage()
+ {
+ var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 10, page: 1, pageSize: 3);
+
+ Assert.False(page.HasPreviousPage);
+ }
+
+ [Fact]
+ public void Map_TransformsItems_PreservesPagination()
+ {
+ var page = PaginatedResponse.Create(new[] { 1, 2, 3 }, totalCount: 10, page: 2, pageSize: 3);
+
+ var mapped = page.Map(x => x.ToString());
+
+ Assert.Equal(new[] { "1", "2", "3" }, mapped.Items);
+ Assert.Equal(10, mapped.TotalCount);
+ Assert.Equal(2, mapped.Page);
+ Assert.Equal(3, mapped.PageSize);
+ }
+
+ [Fact]
+ public void CreateAsync_PaginatesQueryable()
+ {
+ var source = Enumerable.Range(1, 20).AsQueryable();
+
+ var page = PaginatedResponse.CreateAsync(source, page: 2, pageSize: 5).Result;
+
+ Assert.Equal(new[] { 6, 7, 8, 9, 10 }, page.Items);
+ Assert.Equal(20, page.TotalCount);
+ Assert.Equal(2, page.Page);
+ Assert.Equal(5, page.PageSize);
+ Assert.Equal(4, page.TotalPages);
+ }
+
+ [Fact]
+ public void Empty_Page()
+ {
+ var page = PaginatedResponse.Create(
+ Array.Empty(), totalCount: 0, page: 1, pageSize: 10);
+
+ Assert.Empty(page.Items);
+ Assert.Equal(0, page.TotalPages);
+ Assert.False(page.HasNextPage);
+ Assert.False(page.HasPreviousPage);
+ }
+}
diff --git a/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs
new file mode 100644
index 0000000..6badc0c
--- /dev/null
+++ b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs
@@ -0,0 +1,128 @@
+using ZibStack.NET.Dto;
+
+namespace ZibStack.NET.Dto.Tests;
+
+[QueryDto(Sortable = true, DefaultSort = "Name")]
+public class Product
+{
+ [DtoIgnore]
+ public int Id { get; set; }
+
+ public string Name { get; set; } = "";
+ public decimal Price { get; set; }
+ public int Stock { get; set; }
+}
+
+public class SortableQueryTests
+{
+ private static readonly List Products = new()
+ {
+ new() { Id = 1, Name = "Cherry", Price = 3m, Stock = 10 },
+ new() { Id = 2, Name = "Apple", Price = 1m, Stock = 30 },
+ new() { Id = 3, Name = "Banana", Price = 2m, Stock = 20 },
+ };
+
+ [Fact]
+ public void SortableQuery_HasSortByProperty()
+ {
+ var prop = typeof(ProductQuery).GetProperty("SortBy");
+ Assert.NotNull(prop);
+ Assert.Equal(typeof(string), prop.PropertyType);
+ }
+
+ [Fact]
+ public void SortableQuery_HasSortDirectionProperty()
+ {
+ var prop = typeof(ProductQuery).GetProperty("SortDirection");
+ Assert.NotNull(prop);
+ }
+
+ [Fact]
+ public void SortableQuery_HasApplySortMethod()
+ {
+ Assert.NotNull(typeof(ProductQuery).GetMethod("ApplySort"));
+ }
+
+ [Fact]
+ public void SortableQuery_HasApplyMethod()
+ {
+ Assert.NotNull(typeof(ProductQuery).GetMethod("Apply"));
+ }
+
+ [Fact]
+ public void ApplySort_ByName_Asc()
+ {
+ var query = new ProductQuery { SortBy = "Name", SortDirection = SortDirection.Asc };
+ var result = query.ApplySort(Products.AsQueryable()).ToList();
+
+ Assert.Equal("Apple", result[0].Name);
+ Assert.Equal("Banana", result[1].Name);
+ Assert.Equal("Cherry", result[2].Name);
+ }
+
+ [Fact]
+ public void ApplySort_ByPrice_Desc()
+ {
+ var query = new ProductQuery { SortBy = "Price", SortDirection = SortDirection.Desc };
+ var result = query.ApplySort(Products.AsQueryable()).ToList();
+
+ Assert.Equal(3m, result[0].Price);
+ Assert.Equal(2m, result[1].Price);
+ Assert.Equal(1m, result[2].Price);
+ }
+
+ [Fact]
+ public void ApplySort_CaseInsensitive()
+ {
+ var query = new ProductQuery { SortBy = "pRiCe", SortDirection = SortDirection.Asc };
+ var result = query.ApplySort(Products.AsQueryable()).ToList();
+
+ Assert.Equal(1m, result[0].Price);
+ }
+
+ [Fact]
+ public void ApplySort_DefaultSort_UsedWhenNoSortBy()
+ {
+ var query = new ProductQuery(); // No SortBy → defaults to "Name"
+ var result = query.ApplySort(Products.AsQueryable()).ToList();
+
+ Assert.Equal("Apple", result[0].Name);
+ Assert.Equal("Banana", result[1].Name);
+ Assert.Equal("Cherry", result[2].Name);
+ }
+
+ [Fact]
+ public void ApplySort_UnknownField_ReturnsUnchanged()
+ {
+ var query = new ProductQuery { SortBy = "NonExistent" };
+ var result = query.ApplySort(Products.AsQueryable()).ToList();
+
+ Assert.Equal(3, result.Count); // no crash, returns original order
+ }
+
+ [Fact]
+ public void Apply_FiltersAndSorts()
+ {
+ var query = new ProductQuery
+ {
+ Stock = 20,
+ SortBy = "Name",
+ SortDirection = SortDirection.Asc
+ };
+
+ var result = query.Apply(Products.AsQueryable()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Banana", result[0].Name);
+ }
+
+ [Fact]
+ public void ApplyFilter_StillWorks()
+ {
+ var query = new ProductQuery { Name = "Apple" };
+ var result = query.ApplyFilter(Products.AsQueryable()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Apple", result[0].Name);
+ }
+}
diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs
index 5e1a0e6..bdd424b 100644
--- a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs
+++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs
@@ -21,24 +21,48 @@ public static void LogTraceEx(this ILogger logger, ref ZibLogInterpolatedStringH
logger.Log(LogLevel.Trace, handler.GetTemplate(), handler.GetArgs());
}
+ public static void LogTraceEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler)
+ {
+ if (logger.IsEnabled(LogLevel.Trace))
+ logger.Log(LogLevel.Trace, exception, handler.GetTemplate(), handler.GetArgs());
+ }
+
public static void LogDebugEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler)
{
if (logger.IsEnabled(LogLevel.Debug))
logger.Log(LogLevel.Debug, handler.GetTemplate(), handler.GetArgs());
}
+ public static void LogDebugEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler)
+ {
+ if (logger.IsEnabled(LogLevel.Debug))
+ logger.Log(LogLevel.Debug, exception, handler.GetTemplate(), handler.GetArgs());
+ }
+
public static void LogInformationEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler)
{
if (logger.IsEnabled(LogLevel.Information))
logger.Log(LogLevel.Information, handler.GetTemplate(), handler.GetArgs());
}
+ public static void LogInformationEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler)
+ {
+ if (logger.IsEnabled(LogLevel.Information))
+ logger.Log(LogLevel.Information, exception, handler.GetTemplate(), handler.GetArgs());
+ }
+
public static void LogWarningEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler)
{
if (logger.IsEnabled(LogLevel.Warning))
logger.Log(LogLevel.Warning, handler.GetTemplate(), handler.GetArgs());
}
+ public static void LogWarningEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler)
+ {
+ if (logger.IsEnabled(LogLevel.Warning))
+ logger.Log(LogLevel.Warning, exception, handler.GetTemplate(), handler.GetArgs());
+ }
+
public static void LogErrorEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler)
{
if (logger.IsEnabled(LogLevel.Error))
diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs
new file mode 100644
index 0000000..420c0cc
--- /dev/null
+++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+
+namespace ZibStack.NET.Log;
+
+///
+/// Exception that preserves structured logging data from interpolated strings.
+/// Use like a normal exception with interpolated strings — the template and properties
+/// are captured automatically for structured logging.
+///
+///
+///
+/// throw new ZibException($"Order {orderId} not found for user {userId}");
+/// // Message: "Order 123 not found for user 42"
+/// // Template: "Order {orderId} not found for user {userId}"
+/// // Properties: { orderId: 123, userId: 42 }
+///
+/// // Later when catching:
+/// catch (ZibException ex)
+/// {
+/// ex.LogTo(logger, LogLevel.Error);
+/// // Structured log: "Order {orderId} not found for user {userId}" with orderId=123, userId=42
+/// }
+///
+///
+public class ZibException : Exception
+{
+ /// The structured message template (e.g. "Order {orderId} not found").
+ public string Template { get; }
+
+ /// Captured property names and values from the interpolated string.
+ public IReadOnlyList> Properties { get; }
+
+ public ZibException(ref ZibExceptionInterpolatedStringHandler handler)
+ : base(handler.GetMessage())
+ {
+ Template = handler.GetTemplate();
+ Properties = handler.GetProperties();
+ }
+
+ public ZibException(ref ZibExceptionInterpolatedStringHandler handler, Exception innerException)
+ : base(handler.GetMessage(), innerException)
+ {
+ Template = handler.GetTemplate();
+ Properties = handler.GetProperties();
+ }
+
+ ///
+ /// Log this exception with structured data preserved.
+ ///
+ public void LogTo(ILogger logger, LogLevel level = LogLevel.Error)
+ {
+ if (!logger.IsEnabled(level)) return;
+
+ var args = new object?[Properties.Count];
+ for (int i = 0; i < Properties.Count; i++)
+ args[i] = Properties[i].Value;
+
+ logger.Log(level, this, Template, args);
+ }
+}
+
+///
+/// Typed version of ZibException for domain-specific exception hierarchies.
+///
+public class ZibException : ZibException where TCode : struct, Enum
+{
+ /// Application-specific error code.
+ public TCode Code { get; }
+
+ public ZibException(TCode code, ref ZibExceptionInterpolatedStringHandler handler)
+ : base(ref handler)
+ {
+ Code = code;
+ }
+
+ public ZibException(TCode code, ref ZibExceptionInterpolatedStringHandler handler, Exception innerException)
+ : base(ref handler, innerException)
+ {
+ Code = code;
+ }
+}
+
+///
+/// Extensions for logging any exception, with special handling for ZibException.
+///
+public static class ZibExceptionLoggerExtensions
+{
+ ///
+ /// Logs the exception. If it's a ZibException, preserves structured data.
+ /// Otherwise falls back to standard exception logging.
+ ///
+ public static void LogException(this ILogger logger, Exception exception, LogLevel level = LogLevel.Error)
+ {
+ if (!logger.IsEnabled(level)) return;
+
+ if (exception is ZibException zibEx)
+ {
+ zibEx.LogTo(logger, level);
+ }
+ else
+ {
+ logger.Log(level, exception, exception.Message);
+ }
+ }
+}
diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs
new file mode 100644
index 0000000..b7d19dc
--- /dev/null
+++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace ZibStack.NET.Log;
+
+///
+/// Interpolated string handler for exceptions that captures both the formatted message
+/// and the structured template + properties for structured logging.
+///
+[InterpolatedStringHandler]
+public ref struct ZibExceptionInterpolatedStringHandler
+{
+ private readonly StringBuilder _template;
+ private readonly StringBuilder _message;
+ private readonly List> _properties;
+
+ public ZibExceptionInterpolatedStringHandler(int literalLength, int formattedCount)
+ {
+ _template = new StringBuilder(literalLength + formattedCount * 16);
+ _message = new StringBuilder(literalLength + formattedCount * 16);
+ _properties = new List>(formattedCount);
+ }
+
+ public void AppendLiteral(string s)
+ {
+ _template.Append(s.Replace("{", "{{").Replace("}", "}}"));
+ _message.Append(s);
+ }
+
+ public void AppendFormatted(
+ T value,
+ [CallerArgumentExpression(nameof(value))] string name = "")
+ {
+ var sanitized = SanitizeName(name);
+ _template.Append('{').Append(sanitized).Append('}');
+ _message.Append(value);
+ _properties.Add(new KeyValuePair(sanitized, value));
+ }
+
+ public void AppendFormatted(
+ T value,
+ string format,
+ [CallerArgumentExpression(nameof(value))] string name = "")
+ {
+ var sanitized = SanitizeName(name);
+ _template.Append('{').Append(sanitized);
+ if (!string.IsNullOrEmpty(format))
+ _template.Append(':').Append(format);
+ _template.Append('}');
+
+ if (value is IFormattable formattable)
+ _message.Append(formattable.ToString(format, null));
+ else
+ _message.Append(value);
+
+ _properties.Add(new KeyValuePair(sanitized, value));
+ }
+
+ internal readonly string GetMessage() => _message.ToString();
+ internal readonly string GetTemplate() => _template.ToString();
+ internal readonly IReadOnlyList> GetProperties() => _properties;
+
+ private static string SanitizeName(string expression)
+ {
+ if (string.IsNullOrEmpty(expression))
+ return "_";
+
+ var sb = new StringBuilder(expression.Length);
+ bool capitalizeNext = false;
+
+ for (int i = 0; i < expression.Length; i++)
+ {
+ char c = expression[i];
+ if (char.IsLetterOrDigit(c) || c == '_')
+ {
+ sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : c);
+ capitalizeNext = false;
+ }
+ else
+ {
+ capitalizeNext = sb.Length > 0;
+ }
+ }
+
+ return sb.Length > 0 ? sb.ToString() : "_";
+ }
+}
diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs
new file mode 100644
index 0000000..8c7053c
--- /dev/null
+++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs
@@ -0,0 +1,100 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace ZibStack.NET.Log.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class UseLogExAnalyzer : DiagnosticAnalyzer
+{
+ public const string DiagnosticId = "ZLOG001";
+
+ private static readonly LocalizableString Title = "Use LogXxxEx for interpolated strings";
+ private static readonly LocalizableString MessageFormat = "Use '{0}' instead of '{1}' with interpolated strings for structured logging";
+ private static readonly LocalizableString Description = "Standard ILogger.LogXxx methods with interpolated strings lose structured logging data. Use the ZibStack.NET.Log LogXxxEx extension methods instead, which preserve property names for structured logging sinks like Serilog/Seq.";
+
+ private static readonly DiagnosticDescriptor Rule = new(
+ DiagnosticId,
+ Title,
+ MessageFormat,
+ "Usage",
+ DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: Description);
+
+ private static readonly Dictionary MethodMapping = new()
+ {
+ { "LogTrace", "LogTraceEx" },
+ { "LogDebug", "LogDebugEx" },
+ { "LogInformation", "LogInformationEx" },
+ { "LogWarning", "LogWarningEx" },
+ { "LogError", "LogErrorEx" },
+ { "LogCritical", "LogCriticalEx" },
+ };
+
+ public override ImmutableArray SupportedDiagnostics
+ => ImmutableArray.Create(Rule);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
+ }
+
+ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
+ {
+ var invocation = (InvocationExpressionSyntax)context.Node;
+
+ // Must be a member access: logger.LogXxx(...)
+ if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
+ return;
+
+ var methodName = memberAccess.Name.Identifier.Text;
+
+ // Quick check: is it one of the standard log methods?
+ if (!MethodMapping.TryGetValue(methodName, out var suggestedMethod))
+ return;
+
+ // Check if any argument is an interpolated string
+ var hasInterpolatedArg = invocation.ArgumentList.Arguments
+ .Any(arg => arg.Expression is InterpolatedStringExpressionSyntax);
+
+ if (!hasInterpolatedArg)
+ return;
+
+ // Verify the method is on ILogger (from Microsoft.Extensions.Logging)
+ var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken);
+ if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
+ return;
+
+ var containingType = methodSymbol.ContainingType?.ToDisplayString();
+
+ // Match both the extension class and direct ILogger calls
+ if (containingType != "Microsoft.Extensions.Logging.LoggerExtensions"
+ && !IsILoggerType(methodSymbol.ContainingType))
+ return;
+
+ var diagnostic = Diagnostic.Create(
+ Rule,
+ memberAccess.Name.GetLocation(),
+ suggestedMethod,
+ methodName);
+
+ context.ReportDiagnostic(diagnostic);
+ }
+
+ private static bool IsILoggerType(INamedTypeSymbol? type)
+ {
+ if (type is null) return false;
+
+ // Check if the type implements ILogger
+ return type.AllInterfaces.Any(i =>
+ i.ToDisplayString() == "Microsoft.Extensions.Logging.ILogger")
+ || type.ToDisplayString() == "Microsoft.Extensions.Logging.ILogger";
+ }
+}
diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs
new file mode 100644
index 0000000..c1a53a3
--- /dev/null
+++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs
@@ -0,0 +1,57 @@
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace ZibStack.NET.Log.Analyzers;
+
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseLogExCodeFixProvider)), Shared]
+public sealed class UseLogExCodeFixProvider : CodeFixProvider
+{
+ public override ImmutableArray FixableDiagnosticIds
+ => ImmutableArray.Create(UseLogExAnalyzer.DiagnosticId);
+
+ public override FixAllProvider GetFixAllProvider()
+ => WellKnownFixAllProviders.BatchFixer;
+
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null) return;
+
+ var diagnostic = context.Diagnostics.First();
+ var diagnosticSpan = diagnostic.Location.SourceSpan;
+
+ var node = root.FindNode(diagnosticSpan);
+ if (node is not SimpleNameSyntax identifierName) return;
+
+ var currentName = identifierName.Identifier.Text;
+ var newName = currentName + "Ex";
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: $"Use '{newName}' instead",
+ createChangedDocument: ct => ReplaceMethodNameAsync(context.Document, identifierName, newName, ct),
+ equivalenceKey: UseLogExAnalyzer.DiagnosticId),
+ diagnostic);
+ }
+
+ private static async Task ReplaceMethodNameAsync(
+ Document document, SimpleNameSyntax identifierName, string newName, CancellationToken cancellationToken)
+ {
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root is null) return document;
+
+ var newIdentifier = SyntaxFactory.IdentifierName(newName)
+ .WithTriviaFrom(identifierName);
+
+ var newRoot = root.ReplaceNode(identifierName, newIdentifier);
+ return document.WithSyntaxRoot(newRoot);
+ }
+}
diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj
new file mode 100644
index 0000000..ac90ff7
--- /dev/null
+++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj
@@ -0,0 +1,32 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+ true
+ RS2008
+ true
+
+ ZibStack.NET.Log.Analyzers
+ Roslyn analyzer that detects logger.LogXxx($"...") calls and suggests using LogXxxEx() for structured logging.
+ $(Authors)
+ MIT
+ https://github.com/MistyKuu/ZibStack.NET
+ https://github.com/MistyKuu/ZibStack.NET
+ analyzer;logging;roslyn;csharp
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs
new file mode 100644
index 0000000..7d8719f
--- /dev/null
+++ b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs
@@ -0,0 +1,157 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using ZibStack.NET.Log.Analyzers;
+
+namespace ZibStack.NET.Log.Analyzers.Tests;
+
+using Verify = CSharpAnalyzerVerifier;
+
+public class UseLogExAnalyzerTests
+{
+ private const string LoggerStubs = @"
+namespace Microsoft.Extensions.Logging
+{
+ public enum LogLevel { Trace, Debug, Information, Warning, Error, Critical }
+
+ public interface ILogger
+ {
+ bool IsEnabled(LogLevel logLevel);
+ }
+
+ public static class LoggerExtensions
+ {
+ public static void LogTrace(this ILogger logger, string message, params object[] args) { }
+ public static void LogDebug(this ILogger logger, string message, params object[] args) { }
+ public static void LogInformation(this ILogger logger, string message, params object[] args) { }
+ public static void LogWarning(this ILogger logger, string message, params object[] args) { }
+ public static void LogError(this ILogger logger, string message, params object[] args) { }
+ public static void LogCritical(this ILogger logger, string message, params object[] args) { }
+ }
+}
+";
+
+ [Fact]
+ public async Task LogInformation_WithInterpolatedString_Reports()
+ {
+ var test = @"
+using Microsoft.Extensions.Logging;
+
+class MyService
+{
+ void Do(ILogger logger, string name)
+ {
+ logger.{|#0:LogInformation|}($""Hello {name}"");
+ }
+}
+" + LoggerStubs;
+
+ var expected = Verify.Diagnostic(UseLogExAnalyzer.DiagnosticId)
+ .WithLocation(0)
+ .WithArguments("LogInformationEx", "LogInformation");
+
+ await Verify.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task LogWarning_WithInterpolatedString_Reports()
+ {
+ var test = @"
+using Microsoft.Extensions.Logging;
+
+class MyService
+{
+ void Do(ILogger logger, int count)
+ {
+ logger.{|#0:LogWarning|}($""Count is {count}"");
+ }
+}
+" + LoggerStubs;
+
+ var expected = Verify.Diagnostic(UseLogExAnalyzer.DiagnosticId)
+ .WithLocation(0)
+ .WithArguments("LogWarningEx", "LogWarning");
+
+ await Verify.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task LogError_WithInterpolatedString_Reports()
+ {
+ var test = @"
+using Microsoft.Extensions.Logging;
+
+class MyService
+{
+ void Do(ILogger logger, string err)
+ {
+ logger.{|#0:LogError|}($""Error: {err}"");
+ }
+}
+" + LoggerStubs;
+
+ var expected = Verify.Diagnostic(UseLogExAnalyzer.DiagnosticId)
+ .WithLocation(0)
+ .WithArguments("LogErrorEx", "LogError");
+
+ await Verify.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task LogInformation_WithPlainString_NoDiagnostic()
+ {
+ var test = @"
+using Microsoft.Extensions.Logging;
+
+class MyService
+{
+ void Do(ILogger logger)
+ {
+ logger.LogInformation(""Hello world"");
+ }
+}
+" + LoggerStubs;
+
+ await Verify.VerifyAnalyzerAsync(test);
+ }
+
+ [Fact]
+ public async Task LogInformation_WithTemplateString_NoDiagnostic()
+ {
+ var test = @"
+using Microsoft.Extensions.Logging;
+
+class MyService
+{
+ void Do(ILogger logger, string name)
+ {
+ logger.LogInformation(""Hello {Name}"", name);
+ }
+}
+" + LoggerStubs;
+
+ await Verify.VerifyAnalyzerAsync(test);
+ }
+
+ [Fact]
+ public async Task NonLoggerMethod_WithInterpolatedString_NoDiagnostic()
+ {
+ var test = @"
+class SomeOther
+{
+ public void LogInformation(string msg) { }
+}
+
+class MyService
+{
+ void Do()
+ {
+ var other = new SomeOther();
+ other.LogInformation($""Hello {42}"");
+ }
+}
+";
+
+ await Verify.VerifyAnalyzerAsync(test);
+ }
+}
diff --git a/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj
new file mode 100644
index 0000000..df70dd4
--- /dev/null
+++ b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ZibStack.NET.Result/README.md b/packages/ZibStack.NET.Result/README.md
new file mode 100644
index 0000000..ab218b9
--- /dev/null
+++ b/packages/ZibStack.NET.Result/README.md
@@ -0,0 +1,198 @@
+# ZibStack.NET.Result
+
+Functional `Result` monad for .NET — eliminate manual error propagation across layers.
+
+## Install
+
+```bash
+dotnet add package ZibStack.NET.Result
+```
+
+## The Problem
+
+```csharp
+// Every layer re-checks and re-wraps errors manually:
+Result orderResult = _orderService.GetOrder(id);
+if (orderResult.IsFailure)
+ return Result.Failure(orderResult.Error); // maintenance hell
+
+var dto = MapToDto(orderResult.Value);
+return Result.Success(dto);
+```
+
+## The Solution
+
+```csharp
+// Map — transform the value, errors propagate automatically
+Result dto = _orderService.GetOrder(id)
+ .Map(order => MapToDto(order));
+
+// Bind — chain Result-returning calls
+Result result = _orderService.GetOrder(id)
+ .Bind(order => _shipmentService.GetShipment(order.ShipmentId))
+ .Map(shipment => MapToDto(shipment));
+```
+
+## Quick Start
+
+### Creating Results
+
+```csharp
+// Success
+Result ok = Result.Success(42);
+
+// Failure
+Result fail = Result.Failure(Error.NotFound("Order not found"));
+
+// Implicit conversions
+Result ok2 = 42;
+Result fail2 = Error.Validation("Invalid input");
+
+// From nullable
+Order? order = db.Find(id);
+Result result = order.ToResult(Error.NotFound($"Order {id} not found"));
+```
+
+### Built-in Error Types
+
+```csharp
+Error.Validation("Name is required")
+Error.NotFound("User not found")
+Error.Conflict("Email already exists")
+Error.Unauthorized("Invalid token")
+Error.Forbidden("Insufficient permissions")
+Error.Unexpected("Something went wrong")
+
+// Aggregate errors
+Error.Validation("Multiple errors", new[] { error1, error2 })
+```
+
+### Map & Bind
+
+```csharp
+// Map — transform value (T → K)
+Result name = GetUser(id)
+ .Map(user => user.Name);
+
+// Bind — chain operations (T → Result)
+Result order = GetUser(id)
+ .Bind(user => GetLatestOrder(user.Id));
+
+// Chain multiple
+Result tracking = GetUser(id)
+ .Bind(user => GetLatestOrder(user.Id))
+ .Bind(order => GetShipment(order.ShipmentId))
+ .Map(shipment => shipment.TrackingNumber);
+```
+
+### Match & Switch
+
+```csharp
+// Match — extract a value from either path
+string message = result.Match(
+ value => $"Found: {value}",
+ error => $"Error: {error.Message}");
+
+// Switch — execute side effects
+result.Switch(
+ value => Console.WriteLine($"OK: {value}"),
+ error => logger.LogError(error.Message));
+```
+
+### Ensure
+
+```csharp
+Result result = GetAge()
+ .Ensure(age => age >= 18, Error.Validation("Must be 18+"))
+ .Ensure(age => age <= 120, Error.Validation("Invalid age"));
+```
+
+### Tap
+
+```csharp
+// Execute a side effect without changing the result
+var result = GetOrder(id)
+ .Tap(order => logger.LogInformation("Found order {Id}", order.Id))
+ .Map(order => MapToDto(order));
+```
+
+### Fallbacks
+
+```csharp
+Result config = LoadFromFile()
+ .OrElse(_ => LoadFromEnvironment())
+ .OrElse(_ => Result.Success(Config.Default));
+```
+
+## Async Support
+
+Full async pipeline support — chain `Task>` without awaiting each step:
+
+```csharp
+Result result = await GetOrderAsync(id)
+ .BindAsync(order => GetShipmentAsync(order.ShipmentId))
+ .MapAsync(shipment => MapToDto(shipment));
+
+// Async mapper/binder
+Result data = await GetUrlAsync(id)
+ .BindAsync(async url =>
+ {
+ var response = await httpClient.GetAsync(url);
+ return response.IsSuccessStatusCode
+ ? Result.Success(await response.Content.ReadAsByteArrayAsync())
+ : Result.Failure(Error.Unexpected("Download failed"));
+ });
+
+// TapAsync with async side effect
+var result = await GetOrderAsync(id)
+ .TapAsync(async order => await PublishEventAsync(order))
+ .MapAsync(order => MapToDto(order));
+```
+
+Supported async extensions: `MapAsync`, `BindAsync`, `MatchAsync`, `TapAsync`, `SwitchAsync`, `GetValueOrDefaultAsync`, `OrElseAsync`.
+
+Both `Task>` and `ValueTask>` overloads are provided.
+
+## Combining Results
+
+```csharp
+// Combine — fail on first error
+var results = new[] { Validate(a), Validate(b), Validate(c) };
+Result> combined = results.Combine();
+
+// CombineAll — collect ALL errors
+Result> all = results.CombineAll();
+// all.Error.InnerErrors contains every individual error
+```
+
+## Real-World Example
+
+```csharp
+public class OrderService
+{
+ public async Task> PlaceOrderAsync(CreateOrderRequest request)
+ {
+ return await ValidateRequest(request)
+ .BindAsync(req => FindCustomerAsync(req.CustomerId))
+ .BindAsync(customer => CreateOrderAsync(customer, request))
+ .TapAsync(order => SendConfirmationEmailAsync(order))
+ .MapAsync(order => new OrderConfirmation(order.Id, order.Total));
+ }
+
+ private Result ValidateRequest(CreateOrderRequest request)
+ {
+ return Result.Success(request)
+ .Ensure(r => r.Items.Count > 0, Error.Validation("Order must have items"))
+ .Ensure(r => r.CustomerId > 0, Error.Validation("Invalid customer"));
+ }
+}
+```
+
+## Requirements
+
+- .NET 8.0+ (async extensions)
+- .NET Standard 2.0 (core Result/Error types)
+
+## License
+
+MIT
diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs
new file mode 100644
index 0000000..acc9873
--- /dev/null
+++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+
+namespace ZibStack.NET.Result;
+
+///
+/// Represents a structured error with a code and message.
+///
+public sealed class Error
+{
+ public string Code { get; }
+ public string Message { get; }
+ public IReadOnlyList InnerErrors { get; }
+
+ public Error(string code, string message, IReadOnlyList? innerErrors = null)
+ {
+ Code = code ?? throw new ArgumentNullException(nameof(code));
+ Message = message ?? throw new ArgumentNullException(nameof(message));
+ InnerErrors = innerErrors ?? Array.Empty();
+ }
+
+ public static Error Validation(string message, IReadOnlyList? innerErrors = null)
+ => new("Validation", message, innerErrors);
+
+ public static Error NotFound(string message)
+ => new("NotFound", message);
+
+ public static Error Conflict(string message)
+ => new("Conflict", message);
+
+ public static Error Unauthorized(string message)
+ => new("Unauthorized", message);
+
+ public static Error Forbidden(string message)
+ => new("Forbidden", message);
+
+ public static Error Unexpected(string message)
+ => new("Unexpected", message);
+
+ public override string ToString() => $"[{Code}] {Message}";
+
+ public override bool Equals(object? obj)
+ => obj is Error other && Code == other.Code && Message == other.Message;
+
+ public override int GetHashCode()
+ {
+#if NET8_0_OR_GREATER
+ return HashCode.Combine(Code, Message);
+#else
+ unchecked
+ {
+ return (Code.GetHashCode() * 397) ^ Message.GetHashCode();
+ }
+#endif
+ }
+}
diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs
new file mode 100644
index 0000000..2b8c615
--- /dev/null
+++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs
@@ -0,0 +1,152 @@
+using System;
+
+namespace ZibStack.NET.Result;
+
+///
+/// Represents the outcome of an operation that does not return a value.
+///
+public readonly struct Result
+{
+ private readonly Error? _error;
+
+ private Result(Error? error)
+ {
+ _error = error;
+ }
+
+ public bool IsSuccess => _error is null;
+ public bool IsFailure => _error is not null;
+
+ public Error Error => _error ?? throw new InvalidOperationException("Cannot access Error on a successful result.");
+
+ public static Result Success() => new(null);
+
+ public static Result Failure(Error error) => new(error ?? throw new ArgumentNullException(nameof(error)));
+
+ public static Result Success(T value) => Result.Success(value);
+
+ public static Result Failure(Error error) => Result.Failure(error);
+
+ ///
+ /// Pattern match on the result.
+ ///
+ public TOut Match(Func onSuccess, Func onFailure)
+ => IsSuccess ? onSuccess() : onFailure(_error!);
+
+ ///
+ /// Execute an action based on the result.
+ ///
+ public void Switch(Action onSuccess, Action onFailure)
+ {
+ if (IsSuccess)
+ onSuccess();
+ else
+ onFailure(_error!);
+ }
+
+ public override string ToString()
+ => IsSuccess ? "Success" : $"Failure({_error})";
+}
+
+///
+/// Represents the outcome of an operation that returns a value of type .
+///
+public readonly struct Result
+{
+ private readonly T? _value;
+ private readonly Error? _error;
+
+ private Result(T value)
+ {
+ _value = value;
+ _error = null;
+ }
+
+ private Result(Error error)
+ {
+ _value = default;
+ _error = error;
+ }
+
+ public bool IsSuccess => _error is null;
+ public bool IsFailure => _error is not null;
+
+ public T Value => IsSuccess
+ ? _value!
+ : throw new InvalidOperationException("Cannot access Value on a failed result.");
+
+ public Error Error => _error ?? throw new InvalidOperationException("Cannot access Error on a successful result.");
+
+ public static Result Success(T value) => new(value);
+
+ public static Result Failure(Error error) => new(error ?? throw new ArgumentNullException(nameof(error)));
+
+ ///
+ /// Transform the success value. If the result is a failure, the error propagates automatically.
+ ///
+ public Result Map(Func map)
+ => IsSuccess ? Result.Success(map(_value!)) : Result.Failure(_error!);
+
+ ///
+ /// Chain another Result-returning operation. If the result is a failure, the error propagates automatically.
+ ///
+ public Result Bind(Func> bind)
+ => IsSuccess ? bind(_value!) : Result.Failure(_error!);
+
+ ///
+ /// Pattern match on the result.
+ ///
+ public TOut Match(Func onSuccess, Func onFailure)
+ => IsSuccess ? onSuccess(_value!) : onFailure(_error!);
+
+ ///
+ /// Execute an action based on the result.
+ ///
+ public void Switch(Action onSuccess, Action onFailure)
+ {
+ if (IsSuccess)
+ onSuccess(_value!);
+ else
+ onFailure(_error!);
+ }
+
+ ///
+ /// Execute a side effect on success, then return the original result.
+ ///
+ public Result Tap(Action action)
+ {
+ if (IsSuccess)
+ action(_value!);
+ return this;
+ }
+
+ ///
+ /// Returns the value if success, or the provided default if failure.
+ ///
+ public T GetValueOrDefault(T defaultValue)
+ => IsSuccess ? _value! : defaultValue;
+
+ ///
+ /// Returns the value if success, or invokes the factory if failure.
+ ///
+ public T GetValueOrDefault(Func factory)
+ => IsSuccess ? _value! : factory(_error!);
+
+ ///
+ /// Convert to a non-generic Result, discarding the value.
+ ///
+ public Result ToResult()
+ => IsSuccess ? Result.Success() : Result.Failure(_error!);
+
+ ///
+ /// Provide a fallback Result if this one failed.
+ ///
+ public Result OrElse(Func> fallback)
+ => IsSuccess ? this : fallback(_error!);
+
+ public static implicit operator Result(T value) => Success(value);
+ public static implicit operator Result(Error error) => Failure(error);
+
+ public override string ToString()
+ => IsSuccess ? $"Success({_value})" : $"Failure({_error})";
+}
diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs
new file mode 100644
index 0000000..b4835ef
--- /dev/null
+++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Threading.Tasks;
+
+namespace ZibStack.NET.Result;
+
+///
+/// Async extensions for Result<T>, enabling fluent async pipelines.
+///
+public static class ResultAsyncExtensions
+{
+ // ── Map ───────────────────────────────────────────────────────────
+
+ public static async Task> MapAsync(
+ this Task> resultTask, Func map)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.Map(map);
+ }
+
+ public static async Task> MapAsync(
+ this Task> resultTask, Func> map)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ if (result.IsFailure)
+ return Result.Failure(result.Error);
+
+ var mapped = await map(result.Value).ConfigureAwait(false);
+ return Result.Success(mapped);
+ }
+
+ public static async ValueTask> MapAsync(
+ this ValueTask> resultTask, Func map)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.Map(map);
+ }
+
+ // ── Bind ──────────────────────────────────────────────────────────
+
+ public static async Task> BindAsync(
+ this Task> resultTask, Func> bind)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.Bind(bind);
+ }
+
+ public static async Task> BindAsync(
+ this Task> resultTask, Func>> bind)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ if (result.IsFailure)
+ return Result.Failure(result.Error);
+
+ return await bind(result.Value).ConfigureAwait(false);
+ }
+
+ public static async ValueTask> BindAsync(
+ this ValueTask> resultTask, Func> bind)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.Bind(bind);
+ }
+
+ public static async ValueTask> BindAsync(
+ this ValueTask> resultTask, Func>> bind)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ if (result.IsFailure)
+ return Result.Failure(result.Error);
+
+ return await bind(result.Value).ConfigureAwait(false);
+ }
+
+ // ── Match ─────────────────────────────────────────────────────────
+
+ public static async Task MatchAsync(
+ this Task> resultTask, Func onSuccess, Func onFailure)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.Match(onSuccess, onFailure);
+ }
+
+ public static async Task MatchAsync(
+ this Task> resultTask, Func> onSuccess, Func> onFailure)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.IsSuccess
+ ? await onSuccess(result.Value).ConfigureAwait(false)
+ : await onFailure(result.Error).ConfigureAwait(false);
+ }
+
+ // ── Tap ───────────────────────────────────────────────────────────
+
+ public static async Task> TapAsync(
+ this Task> resultTask, Action action)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.Tap(action);
+ }
+
+ public static async Task> TapAsync(
+ this Task> resultTask, Func action)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ if (result.IsSuccess)
+ await action(result.Value).ConfigureAwait(false);
+ return result;
+ }
+
+ // ── Switch ────────────────────────────────────────────────────────
+
+ public static async Task SwitchAsync(
+ this Task> resultTask, Action onSuccess, Action onFailure)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ result.Switch(onSuccess, onFailure);
+ }
+
+ // ── GetValueOrDefault ─────────────────────────────────────────────
+
+ public static async Task GetValueOrDefaultAsync(
+ this Task> resultTask, T defaultValue)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.GetValueOrDefault(defaultValue);
+ }
+
+ // ── OrElse ────────────────────────────────────────────────────────
+
+ public static async Task> OrElseAsync(
+ this Task> resultTask, Func> fallback)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ return result.OrElse(fallback);
+ }
+
+ public static async Task> OrElseAsync(
+ this Task> resultTask, Func>> fallback)
+ {
+ var result = await resultTask.ConfigureAwait(false);
+ if (result.IsSuccess)
+ return result;
+
+ return await fallback(result.Error).ConfigureAwait(false);
+ }
+}
diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs
new file mode 100644
index 0000000..6b5a783
--- /dev/null
+++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ZibStack.NET.Result;
+
+///
+/// Utility extensions for working with collections of Results.
+///
+public static class ResultExtensions
+{
+ ///
+ /// Combines multiple results into a single Result containing a list of values.
+ /// If any result is a failure, returns the first error.
+ ///
+ public static Result> Combine(this IEnumerable> results)
+ {
+ var values = new List();
+ foreach (var result in results)
+ {
+ if (result.IsFailure)
+ return Result>.Failure(result.Error);
+ values.Add(result.Value);
+ }
+ return Result>.Success(values);
+ }
+
+ ///
+ /// Combines multiple results, collecting all errors into a single Validation error.
+ ///
+ public static Result> CombineAll(this IEnumerable> results)
+ {
+ var values = new List();
+ var errors = new List();
+
+ foreach (var result in results)
+ {
+ if (result.IsFailure)
+ errors.Add(result.Error);
+ else
+ values.Add(result.Value);
+ }
+
+ if (errors.Count > 0)
+ return Result>.Failure(
+ Error.Validation("One or more operations failed.", errors));
+
+ return Result>.Success(values);
+ }
+
+ ///
+ /// Converts a nullable value to a Result, using the provided error if null.
+ ///
+ public static Result ToResult(this T? value, Error error) where T : class
+ => value is not null ? Result.Success(value) : Result.Failure(error);
+
+ ///
+ /// Converts a nullable value type to a Result, using the provided error if null.
+ ///
+ public static Result ToResult(this T? value, Error error) where T : struct
+ => value.HasValue ? Result.Success(value.Value) : Result.Failure(error);
+
+ ///
+ /// Ensures a condition is met on the value; otherwise returns the provided error.
+ ///
+ public static Result Ensure(this Result result, Func predicate, Error error)
+ {
+ if (result.IsFailure)
+ return result;
+
+ return predicate(result.Value) ? result : Result.Failure(error);
+ }
+}
diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj
new file mode 100644
index 0000000..e1f038c
--- /dev/null
+++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj
@@ -0,0 +1,28 @@
+
+
+
+ netstandard2.0;net8.0
+ latest
+ enable
+ enable
+
+ ZibStack.NET.Result
+ Functional Result monad for .NET with Map, Bind, Match and async support. Eliminate manual error propagation.
+ $(Authors)
+ MIT
+ https://github.com/MistyKuu/ZibStack.NET
+ https://github.com/MistyKuu/ZibStack.NET
+ result;monad;functional;error-handling;csharp
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs
new file mode 100644
index 0000000..287b214
--- /dev/null
+++ b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs
@@ -0,0 +1,149 @@
+using ZibStack.NET.Result;
+
+namespace ZibStack.NET.Result.Tests;
+
+public class ResultAsyncTests
+{
+ // ── MapAsync ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task MapAsync_Success_TransformsValue()
+ {
+ var result = await Task.FromResult(Result.Success(5))
+ .MapAsync(x => x * 2);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(10, result.Value);
+ }
+
+ [Fact]
+ public async Task MapAsync_Failure_PropagatesError()
+ {
+ var result = await Task.FromResult(Result.Failure(Error.NotFound("x")))
+ .MapAsync(x => x * 2);
+
+ Assert.True(result.IsFailure);
+ Assert.Equal("NotFound", result.Error.Code);
+ }
+
+ [Fact]
+ public async Task MapAsync_WithAsyncMapper_Success()
+ {
+ var result = await Task.FromResult(Result.Success(5))
+ .MapAsync(async x =>
+ {
+ await Task.Delay(1);
+ return x.ToString();
+ });
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("5", result.Value);
+ }
+
+ // ── BindAsync ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task BindAsync_Success_ChainsOperation()
+ {
+ var result = await Task.FromResult(Result.Success(10))
+ .BindAsync(x => Result.Success($"val:{x}"));
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("val:10", result.Value);
+ }
+
+ [Fact]
+ public async Task BindAsync_Failure_PropagatesError()
+ {
+ var result = await Task.FromResult(Result.Failure(Error.Unexpected("boom")))
+ .BindAsync(x => Result.Success(x.ToString()));
+
+ Assert.True(result.IsFailure);
+ }
+
+ [Fact]
+ public async Task BindAsync_WithAsyncBinder_Success()
+ {
+ var result = await Task.FromResult(Result.Success(5))
+ .BindAsync(async x =>
+ {
+ await Task.Delay(1);
+ return Result.Success(x.ToString());
+ });
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("5", result.Value);
+ }
+
+ // ── Full async pipeline ───────────────────────────────────────────
+
+ [Fact]
+ public async Task Full_Async_Pipeline()
+ {
+ var result = await GetOrderAsync(1)
+ .BindAsync(order => GetShipmentAsync(order))
+ .MapAsync(shipment => $"shipped:{shipment}");
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("shipped:SHIP-1", result.Value);
+ }
+
+ [Fact]
+ public async Task Full_Async_Pipeline_FailsAtFirstError()
+ {
+ var result = await GetOrderAsync(999) // will fail
+ .BindAsync(order => GetShipmentAsync(order))
+ .MapAsync(shipment => $"shipped:{shipment}");
+
+ Assert.True(result.IsFailure);
+ Assert.Equal("NotFound", result.Error.Code);
+ }
+
+ // ── TapAsync ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task TapAsync_Success_ExecutesSideEffect()
+ {
+ var logged = "";
+ var result = await Task.FromResult(Result.Success("hello"))
+ .TapAsync(v => logged = v);
+
+ Assert.Equal("hello", logged);
+ Assert.Equal("hello", result.Value);
+ }
+
+ // ── MatchAsync ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task MatchAsync_Success_CallsOnSuccess()
+ {
+ var output = await Task.FromResult(Result.Success(42))
+ .MatchAsync(
+ v => $"ok:{v}",
+ e => $"err:{e.Code}");
+
+ Assert.Equal("ok:42", output);
+ }
+
+ // ── OrElseAsync ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task OrElseAsync_Failure_ReturnsFallback()
+ {
+ var result = await Task.FromResult(Result.Failure(Error.NotFound("x")))
+ .OrElseAsync(_ => Result.Success(0));
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(0, result.Value);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────
+
+ private static Task> GetOrderAsync(int id)
+ => Task.FromResult(id == 1
+ ? Result.Success("ORDER-1")
+ : Result.Failure(Error.NotFound($"Order {id} not found")));
+
+ private static Task> GetShipmentAsync(string orderId)
+ => Task.FromResult(Result.Success($"SHIP-{orderId.Split('-')[1]}"));
+}
diff --git a/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs
new file mode 100644
index 0000000..949ace7
--- /dev/null
+++ b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs
@@ -0,0 +1,134 @@
+using ZibStack.NET.Result;
+
+namespace ZibStack.NET.Result.Tests;
+
+public class ResultExtensionsTests
+{
+ // ── Combine ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void Combine_AllSuccess_ReturnsList()
+ {
+ var results = new[]
+ {
+ Result.Success(1),
+ Result.Success(2),
+ Result.Success(3),
+ };
+
+ var combined = results.Combine();
+
+ Assert.True(combined.IsSuccess);
+ Assert.Equal(new[] { 1, 2, 3 }, combined.Value);
+ }
+
+ [Fact]
+ public void Combine_OneFailure_ReturnsFirstError()
+ {
+ var results = new[]
+ {
+ Result.Success(1),
+ Result.Failure(Error.Validation("bad")),
+ Result.Success(3),
+ };
+
+ var combined = results.Combine();
+
+ Assert.True(combined.IsFailure);
+ Assert.Equal("bad", combined.Error.Message);
+ }
+
+ // ── CombineAll ────────────────────────────────────────────────────
+
+ [Fact]
+ public void CombineAll_CollectsAllErrors()
+ {
+ var results = new[]
+ {
+ Result.Failure(Error.Validation("err1")),
+ Result.Success(2),
+ Result.Failure(Error.Validation("err2")),
+ };
+
+ var combined = results.CombineAll();
+
+ Assert.True(combined.IsFailure);
+ Assert.Equal(2, combined.Error.InnerErrors.Count);
+ Assert.Equal("err1", combined.Error.InnerErrors[0].Message);
+ Assert.Equal("err2", combined.Error.InnerErrors[1].Message);
+ }
+
+ // ── ToResult (reference type) ─────────────────────────────────────
+
+ [Fact]
+ public void ToResult_NotNull_ReturnsSuccess()
+ {
+ string? value = "hello";
+ var result = value.ToResult(Error.NotFound("missing"));
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("hello", result.Value);
+ }
+
+ [Fact]
+ public void ToResult_Null_ReturnsFailure()
+ {
+ string? value = null;
+ var result = value.ToResult(Error.NotFound("missing"));
+
+ Assert.True(result.IsFailure);
+ }
+
+ // ── ToResult (value type) ─────────────────────────────────────────
+
+ [Fact]
+ public void ToResult_NullableStruct_HasValue_ReturnsSuccess()
+ {
+ int? value = 42;
+ var result = value.ToResult(Error.NotFound("missing"));
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(42, result.Value);
+ }
+
+ [Fact]
+ public void ToResult_NullableStruct_Null_ReturnsFailure()
+ {
+ int? value = null;
+ var result = value.ToResult(Error.NotFound("missing"));
+
+ Assert.True(result.IsFailure);
+ }
+
+ // ── Ensure ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Ensure_PredicateTrue_ReturnsOriginal()
+ {
+ var result = Result