Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
<!-- CA1812: False positives for DI-instantiated classes -->
<!-- IL2026, IL2067, IL2070, IL2075, IL3050, IL2104, IL3053: AOT warnings (not yet implemented) -->
<!-- IDE0290: Use primary constructor - disabled per project preference -->
<NoWarn>$(NoWarn);CA1014;CA1724;CA1812;IDE0290;IL2026;IL2067;IL2070;IL2075;IL3050;IL2104;IL3053</NoWarn>
<NoWarn>$(NoWarn);CA1014;CA1724;CA1812;IDE0290</NoWarn>
</PropertyGroup>

<!-- Code analyzers applied to all projects -->
Expand Down
8 changes: 6 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup Label="TimeWarp Packages">
<PackageVersion Include="TimeWarp.Amuru" Version="1.0.0-beta.33" />
<PackageVersion Include="TimeWarp.Amuru" Version="1.0.0-beta.34" />
<PackageVersion Include="TimeWarp.Nuru" Version="3.0.0-beta.71" />
<PackageVersion Include="TimeWarp.Build.Tasks" Version="1.0.0" />
<PackageVersion Include="TimeWarp.Jaribu" Version="1.0.0-beta.13" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="Shouldly" Version="4.3.0" />
</ItemGroup>
<ItemGroup Label="Code Analyzers">
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Roslynator.CodeAnalysis.Analyzers" Version="4.15.0" />
<PackageVersion Include="Roslynator.Formatting.Analyzers" Version="4.15.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.300" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.301" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeStyle" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re
- Add `ToolsDirectory` for future tooling support
- Note: TestsDirectory, SamplesDirectory, BenchmarksDirectory left in place for future use

- [ ] **Add automated tests**
- Create test project (suggested: `tests/TimeWarp.Builder.Tests/`)
- [x] **Add automated tests** (2026-07-02)
- Implemented as TimeWarp.Jaribu runfiles under `tests/` (repo convention) rather than a dotnet-test project; `dev test` runs each runfile and aggregates exit codes
- 21 tests across 6 files: 4 scope-extension files + 2 interface integration files (concrete builders modeled on TimeWarp.Nuru's usage), covering null guards and covariance
- Original scope:
- Test all 4 scope extension methods:
- `Also` - executes action, returns original object
- `Apply` - executes action, returns original object
Expand All @@ -37,15 +39,16 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re

### ⚠️ High Priority (strongly recommended)

- [ ] **Improve README.md**
- [x] **Improve README.md** (2026-07-02)
- Add installation section with `dotnet add package TimeWarp.Builder`
- Add requirements section (target framework: .NET 10.0)
- Add license badge and link to LICENSE file
- Add NuGet version badge
- Add design rationale explaining `Also` vs `Apply` distinction
- Link to GitHub repository

- [ ] **Wire up package icon**
- [x] **Wire up package icon** — resolved with `logo.png` in `source/Directory.Build.props`; ganda's nuget-package-icon audit check passes
- Original suggestion:
- Add `<PackageIcon>timewarp-builder-avatar.png</PackageIcon>` (convert SVG to PNG if needed, NuGet prefers PNG)
- Add icon file reference to `.csproj`:
```xml
Expand All @@ -55,7 +58,7 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re
```
- Note: `assets/timewarp-builder-avatar.svg` exists but SVG icons in NuGet packages have limited client support

- [ ] **Prune `Directory.Packages.props`**
- [x] **Prune `Directory.Packages.props`** (2026-07-02, ganda audit CPM cleanup; Jaribu/Shouldly re-added for tests)
- Remove unused package groups:
- Serilog (Logging - Serilog section)
- OpenTelemetry
Expand All @@ -67,14 +70,15 @@ Code review report: `.agent/workspace/2026-02-23T00-00-00_code-review-release-re
- Keep only packages actually referenced by this library (likely just analyzers and Microsoft.Extensions if needed)
- Note: `TimeWarp.Builder` self-reference can also be removed

- [ ] **Review AOT warning suppressions**
- [x] **Review AOT warning suppressions** (2026-07-02) — removed all seven IL* suppressions; both the library build and the dev-cli AOT publish verified clean without them
- Original scope:
- Current global suppressions in `Directory.Build.props`: `IL2026;IL2067;IL2070;IL2075;IL3050;IL2104;IL3053`
- Evaluate if these are truly needed for this library (it has no reflection/dynamic code)
- If they are false positives, consider removing the global suppression and testing AOT build

### Nice to Have (low priority)

- [ ] Add `#region Purpose` / `#region Design` context blocks to source files per csharp skill conventions
- [x] Add `#region Purpose` / `#region Design` context blocks to source files per csharp skill conventions (2026-07-02)
- [ ] Add `CHANGELOG.md` for v1.0.0 release notes
- [ ] Consider `IBuildAsync<T>` interface for async build scenarios (future v1.x)

Expand Down
64 changes: 58 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
# TimeWarp.Builder

[![NuGet](https://img.shields.io/nuget/vpre/TimeWarp.Builder.svg)](https://www.nuget.org/packages/TimeWarp.Builder)
[![NuGet Downloads](https://img.shields.io/nuget/dt/TimeWarp.Builder.svg)](https://www.nuget.org/packages/TimeWarp.Builder)
[![CI/CD](https://github.com/TimeWarpEngineering/timewarp-builder/actions/workflows/workflow.yml/badge.svg)](https://github.com/TimeWarpEngineering/timewarp-builder/actions/workflows/workflow.yml)
[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](LICENSE)

Fluent builder interfaces and Kotlin-inspired scope extensions for .NET.

## Installation

```bash
dotnet add package TimeWarp.Builder --prerelease
```

## Requirements

- .NET 10.0 or later
- Fully AOT- and trim-compatible (no reflection, no dynamic code)

## Interfaces

### IBuilder\<T\>

Interface for standalone builders that create objects via `Build()`.
Interface for standalone builders that create objects via `Build()`. `TBuilt` is covariant, so an `IBuilder<Derived>` can be used wherever an `IBuilder<Base>` is expected.

```csharp
public class MyWidgetBuilder : IBuilder<Widget>
Expand All @@ -23,7 +39,7 @@ Widget widget = new MyWidgetBuilder()

### INestedBuilder\<TParent\>

Interface for nested builders that return to a parent context via `Done()`.
Interface for nested builders that return to a parent context via `Done()`. `Done()` performs three things: builds the child, hands the result to the parent, and returns the parent for continued chaining.

```csharp
// Nested builder returns to parent after building
Expand All @@ -36,21 +52,38 @@ app.Map(route => route

## Scope Extensions

Kotlin-inspired extension methods for fluent object manipulation.
Kotlin-inspired extension methods for fluent object manipulation. Because they attach to every type (unconstrained `T`), any object can participate in a fluent chain without its type opting in.

| Method | Returns | Use for |
|--------|---------|---------|
| `Also` | The original object | Side effects mid-chain (logging, diagnostics) |
| `Apply` | The original object | Configuring the object mid-chain |
| `Let` | The transform result | Converting to a different type/value |
| `Run` | Nothing (`void`) | Terminal action at the end of a chain |

### Also vs Apply

`Also` and `Apply` are mechanically identical — both execute an action and return the original object. They exist separately to signal *intent* at the call site, mirroring Kotlin's `also`/`apply` distinction: use `Apply` when the action configures the object itself, and `Also` when the action is an incidental side effect like logging.

```csharp
app.Map("status", handler)
.Apply(r => r.AsQuery()) // configures the route
.Also(r => logger.LogDebug("mapped {r}", r)); // side effect, not configuration
```

### Also

Executes an action on the object and returns the original object. Useful for side effects during method chaining.
Executes an action on the object and returns the original object.

```csharp
var builder = new AppBuilder()
.Also(b => Console.WriteLine("Building app..."))
.Also(b => logger.LogDebug("Building app..."))
.Configure(options);
```

### Apply

Configures the object and returns the original object. Semantically similar to `Also` but with clearer intent for configuration.
Configures the object and returns the original object.

```csharp
app.Map("status", handler)
Expand All @@ -72,3 +105,22 @@ Executes an action on the object with no return value. Terminal operation in a m
```csharp
app.Build().Run(a => a.RunAsync(args));
```

All four methods throw `ArgumentNullException` when the delegate is null.

## Used By

- [TimeWarp.Nuru](https://github.com/TimeWarpEngineering/timewarp-nuru) — route, endpoint, group, and key-binding builders implement `IBuilder<T>` / `INestedBuilder<TParent>`
- [TimeWarp.Terminal](https://github.com/TimeWarpEngineering/timewarp-terminal)

## Testing

Tests are [TimeWarp.Jaribu](https://github.com/TimeWarpEngineering/timewarp-jaribu) runfiles under `tests/`. Run them all with `dev test`, or any file directly:

```bash
dotnet run tests/scope-extensions.also.cs
```

## Unlicense

This is free and unencumbered software released into the public domain — see [LICENSE](LICENSE).
2 changes: 1 addition & 1 deletion source/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<!-- Default package metadata (can be overridden in individual projects) -->
<PropertyGroup Label="Package Metadata">
<Version>1.0.0-beta.3</Version>
<Version>1.0.0</Version>
<Authors>Steven T. Cramer</Authors>
<RepositoryUrl>https://github.com/TimeWarpEngineering/timewarp-builder</RepositoryUrl>
<PackageLicenseExpression>Unlicense</PackageLicenseExpression>
Expand Down
4 changes: 4 additions & 0 deletions source/timewarp-builder/global-usings.cs
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#region Purpose
// Global using directives for the TimeWarp.Builder library.
#endregion

global using System;
10 changes: 10 additions & 0 deletions source/timewarp-builder/i-builder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
#region Purpose
// Contract for standalone builders that produce their configured object via Build().
#endregion

#region Design
// TBuilt is covariant (out) so an IBuilder<Derived> is usable where an IBuilder<Base> is
// expected. Builders that finish by returning to a parent context instead implement
// INestedBuilder<TParent>; the two interfaces are intentionally minimal and independent.
#endregion

namespace TimeWarp.Builder;

/// <summary>
Expand Down
11 changes: 11 additions & 0 deletions source/timewarp-builder/i-nested-builder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
#region Purpose
// Contract for nested builders that finish via Done() and return control to a parent builder.
#endregion

#region Design
// Done() bundles three steps: build the child, hand the result to the parent, return the
// parent — enabling deep fluent chains without the caller juggling intermediate results.
// TParent is covariant (out) and constrained to class; nested builders typically wrap a
// standalone IBuilder<TBuilt> internally (see TimeWarp.Nuru's NestedCompiledRouteBuilder).
#endregion

namespace TimeWarp.Builder;

/// <summary>
Expand Down
12 changes: 12 additions & 0 deletions source/timewarp-builder/scope-extensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
#region Purpose
// Kotlin-inspired scope extension methods (Also, Apply, Let, Run) for fluent object manipulation.
#endregion

#region Design
// Also and Apply share an implementation; they exist separately to signal intent at the call
// site (Apply = configuration, Also = incidental side effect), mirroring Kotlin's apply/also.
// T is deliberately unconstrained so the extensions attach to every type — any object can join
// a fluent chain without its type opting in; the IntelliSense noise is the accepted trade-off.
// Delegates are null-guarded; the receiver is not, matching BCL extension-method convention.
#endregion

namespace TimeWarp.Builder;

/// <summary>
Expand Down
Empty file removed tests/.gitkeep
Empty file.
36 changes: 36 additions & 0 deletions tests/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project>
<!-- Import parent Directory.Build.props -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<PropertyGroup>
<!-- Test idioms that conflict with the library-grade analyzer settings -->
<!-- CA1707: Underscores in names - required by the Jaribu naming convention -->
<!-- CA1515/CA1812: Public/uninstantiated types - Jaribu discovers tests via reflection -->
<!-- CA2007: ConfigureAwait not needed in test runfiles -->
<!-- CS1591: XML doc comments not required for tests -->
<!-- CA1859: Prefer concrete types - covariance tests intentionally use interface-typed variables -->
<!-- CA1861: Inline array literals in assertions - test readability over allocation -->
<!-- IDE0058: Discarded expression values - fluent assertions and chained calls -->
<!-- IDE0210/IDE0211: Runfiles must use top-level statements -->
<!-- RCS1046: Async suffix not used in test method names -->
<NoWarn>$(NoWarn);CA1707;CA1515;CA1812;CA1859;CA1861;CA2007;CS1591;IDE0058;IDE0210;IDE0211;RCS1046</NoWarn>
</PropertyGroup>

<ItemGroup>
<!-- Global usings for Jaribu test runfiles -->
<Using Include="System.Collections.Generic" />
<Using Include="System.Runtime.CompilerServices" />
<Using Include="System.Threading.Tasks" />
<Using Include="Shouldly" />
<Using Include="TimeWarp.Builder" />
<Using Include="TimeWarp.Jaribu" />
<Using Include="TimeWarp.Jaribu.TestRunner" Static="true" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="TimeWarp.Jaribu" />
<PackageReference Include="Shouldly" />
<ProjectReference Include="$(RepositoryRoot)source/timewarp-builder/timewarp-builder.csproj" />
</ItemGroup>

</Project>
74 changes: 74 additions & 0 deletions tests/i-builder.build.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env -S dotnet --

#region Purpose
// Tests for IBuilder<TBuilt>: standalone builders produce their configured object via Build().
#endregion

#if !JARIBU_MULTI
return await RunAllTests();
#endif

namespace IBuilder_
{
[TestTag("Interfaces")]
public sealed class Build_Given_
{
[ModuleInitializer]
internal static void Register() => RegisterTests<Build_Given_>();

public static async Task ConcreteBuilder_Should_ReturnConfiguredObject()
{
Widget widget = new WidgetBuilder()
.WithName("gizmo")
.WithSize(10)
.Build();

widget.Name.ShouldBe("gizmo");
widget.Size.ShouldBe(10);
await Task.CompletedTask;
}

public static async Task CovariantTBuilt_Should_AssignToBaseTypedInterface()
{
// IBuilder<out TBuilt> covariance: a Widget builder is usable where an object builder is expected
IBuilder<object> builder = new WidgetBuilder().WithName("gizmo");

builder.Build().ShouldBeOfType<Widget>();
await Task.CompletedTask;
}

public static async Task RepeatedBuild_Should_ProduceIndependentObjects()
{
WidgetBuilder builder = new WidgetBuilder().WithName("gizmo");

Widget first = builder.Build();
Widget second = builder.Build();

first.ShouldNotBeSameAs(second);
first.ShouldBe(second);
await Task.CompletedTask;
}
}

internal sealed record Widget(string Name, int Size);

internal sealed class WidgetBuilder : IBuilder<Widget>
{
private string Name = "";
private int Size;

public WidgetBuilder WithName(string name)
{
Name = name;
return this;
}

public WidgetBuilder WithSize(int size)
{
Size = size;
return this;
}

public Widget Build() => new(Name, Size);
}
}
Loading
Loading