diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..234188d --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add bin diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 120000 index 0000000..50e32c5 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1 @@ +post-commit.cs \ No newline at end of file diff --git a/.githooks/post-commit.cs b/.githooks/post-commit.cs new file mode 100755 index 0000000..397c955 --- /dev/null +++ b/.githooks/post-commit.cs @@ -0,0 +1,18 @@ +#!/usr/bin/env -S dotnet -- +#:package TimeWarp.Amuru +#:property NoWarn=CA2007 + +using TimeWarp.Amuru; + +string? root = Git.FindRoot(); +if (root is null) +{ + return 0; +} + +await Shell.Builder("ganda") + .WithArguments("memsearch", "index-repo", "--background") + .WithWorkingDirectory(root) + .WithNoValidation() + .RunAsync(); +return 0; \ No newline at end of file diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 120000 index 0000000..6031064 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1 @@ +post-merge.cs \ No newline at end of file diff --git a/.githooks/post-merge.cs b/.githooks/post-merge.cs new file mode 100755 index 0000000..397c955 --- /dev/null +++ b/.githooks/post-merge.cs @@ -0,0 +1,18 @@ +#!/usr/bin/env -S dotnet -- +#:package TimeWarp.Amuru +#:property NoWarn=CA2007 + +using TimeWarp.Amuru; + +string? root = Git.FindRoot(); +if (root is null) +{ + return 0; +} + +await Shell.Builder("ganda") + .WithArguments("memsearch", "index-repo", "--background") + .WithWorkingDirectory(root) + .WithNoValidation() + .RunAsync(); +return 0; \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/workflow.yml similarity index 100% rename from .github/workflows/ci-cd.yml rename to .github/workflows/workflow.yml diff --git a/.memsearch.toml b/.memsearch.toml new file mode 100644 index 0000000..00bcc0e --- /dev/null +++ b/.memsearch.toml @@ -0,0 +1,50 @@ +# MemSearch repo knowledge index — TimeWarp convention +# +# SPEC (proposed extension) +# ------------------------- +# memsearch's stock CLI ignores [index]; this file is honored by: +# ganda memsearch index-repo +# ganda hooks install memsearch (post-commit / post-merge runfiles) +# +# Purpose: index markdown knowledge in THIS repo only. One Milvus collection +# per repo (auto-derived from repo path). Global DB file stays at +# ~/.memsearch/milvus.db unless [milvus].uri is overridden below. +# +# [index].paths +# Directory roots (relative to repo root). memsearch indexes all .md files +# under each path recursively. Missing paths are skipped. +# +# [index].exclude_paths +# Directory paths removed from the resolved path list before indexing. +# Does not prune subdirectories when a parent path (e.g. "kanban/") is listed. +# +# [index].exclude_globs +# Reserved — file-level globs (e.g. "**/task-template.md"). Not implemented in v1. +# +# [index].enabled +# false disables hook indexing (conversation plugins still work). +# +# [milvus].collection +# Empty = auto-derive ms__<8char_sha256> from repo root. +# +# Search this repo only: +# memsearch search "query" -c --source-prefix "$(pwd)/kanban/" + +[index] +enabled = true +paths = [ + "kanban", + "documentation", + "okf", + "adr", + ".memsearch/memory", +] +exclude_paths = [] +exclude_globs = [ + "**/task-template.md", + "**/overview.md", +] + +[milvus] +# uri = ".memsearch/milvus.db" # optional per-repo DB file +collection = "" \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 0722879..bf7e992 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,5 +19,6 @@ "titleBar.inactiveForeground": "#15202b99" }, "peacock.remoteColor": "#46A7EA", - "timewarp.blurImagePath": "assets/timewarp-flexbox-avatar.svg" -} \ No newline at end of file + "timewarp.blurImagePath": "assets/timewarp-flexbox-avatar.svg", + "window.title": "timewarp-flexbox · ${rootName}${separator}${activeEditorShort}" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f82f57f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "ganda: window icon", + "type": "shell", + "command": "ganda repo avatar window", + "runOptions": { + "runOn": "folderOpen" + }, + "presentation": { + "reveal": "silent", + "echo": false + }, + "problemMatcher": [] + } + ] +} diff --git a/BannedSymbols.txt b/BannedSymbols.txt new file mode 100644 index 0000000..94da1d3 --- /dev/null +++ b/BannedSymbols.txt @@ -0,0 +1,3 @@ + +T:System.Console;Prefer injecting ITerminal. TimeWarp.Terminal.Terminal static class is available for migration. +T:System.Diagnostics.ProcessStartInfo;Use TimeWarp.Amuru Shell.Builder instead. See the 'amuru' skill for usage patterns. diff --git a/Directory.Build.props b/Directory.Build.props index 68f3dc4..edc9961 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,3 +1,4 @@ + @@ -53,4 +54,4 @@ - + diff --git a/Directory.Packages.props b/Directory.Packages.props index caa2b57..5224b96 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,3 +1,4 @@ + @@ -5,30 +6,27 @@ - - + + allruntime; build; native; contentfiles; analyzers; buildtransitive - - + + - + - + - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3f7c431 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2025-2026 TimeWarp Enterprises, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +TimeWarp.Flexbox is a from-scratch C# implementation of the flexbox layout +algorithm whose behavior is modeled on Facebook's Yoga layout engine +(https://github.com/facebook/yoga), which is licensed under the MIT License, +Copyright (c) Meta Platforms, Inc. and affiliates. diff --git a/benchmarks/timewarp-flexbox-benchmarks/layout-benchmarks.cs b/benchmarks/timewarp-flexbox-benchmarks/layout-benchmarks.cs index 5c55444..20fda81 100644 --- a/benchmarks/timewarp-flexbox-benchmarks/layout-benchmarks.cs +++ b/benchmarks/timewarp-flexbox-benchmarks/layout-benchmarks.cs @@ -9,18 +9,15 @@ namespace TimeWarp.Flexbox.Benchmarks; [SimpleJob] public class LayoutBenchmarks { - private FlexNode SimpleRoot = null!; - private FlexNode DeepRoot = null!; - private FlexNode WideRoot = null!; - private FlexNode FlexGrowRoot = null!; - private FlexNode WrappingRoot = null!; - private FlexLayoutEngine Engine = null!; + private Node SimpleRoot = null!; + private Node DeepRoot = null!; + private Node WideRoot = null!; + private Node FlexGrowRoot = null!; + private Node WrappingRoot = null!; [GlobalSetup] public void Setup() { - Engine = new FlexLayoutEngine(); - // Simple: root with 3 children SimpleRoot = CreateSimpleLayout(); @@ -40,124 +37,156 @@ public void Setup() [Benchmark(Baseline = true)] public void SimpleLayout() { - SimpleRoot.MarkDirty(); - Engine.CalculateLayout(SimpleRoot, 800, 600); + SimpleRoot.MarkDirtyAndPropagate(); + CalculateLayout.Calculate(SimpleRoot, 800, 600, Direction.LTR); } [Benchmark] public void DeepLayout() { - DeepRoot.MarkDirty(); - Engine.CalculateLayout(DeepRoot, 800, 600); + DeepRoot.MarkDirtyAndPropagate(); + CalculateLayout.Calculate(DeepRoot, 800, 600, Direction.LTR); } [Benchmark] public void WideLayout() { - WideRoot.MarkDirty(); - Engine.CalculateLayout(WideRoot, 800, 600); + WideRoot.MarkDirtyAndPropagate(); + CalculateLayout.Calculate(WideRoot, 800, 600, Direction.LTR); } [Benchmark] public void FlexGrowLayout() { - FlexGrowRoot.MarkDirty(); - Engine.CalculateLayout(FlexGrowRoot, 800, 600); + FlexGrowRoot.MarkDirtyAndPropagate(); + CalculateLayout.Calculate(FlexGrowRoot, 800, 600, Direction.LTR); } [Benchmark] public void WrappingLayout() { - WrappingRoot.MarkDirty(); - Engine.CalculateLayout(WrappingRoot, 800, 600); + WrappingRoot.MarkDirtyAndPropagate(); + CalculateLayout.Calculate(WrappingRoot, 800, 600, Direction.LTR); } [Benchmark] public void CachedLayout() { - // Layout already calculated, should hit cache (no MarkDirty) - Engine.CalculateLayout(SimpleRoot, 800, 600); + // Layout already calculated, should hit cache (no dirtying) + CalculateLayout.Calculate(SimpleRoot, 800, 600, Direction.LTR); } - private static FlexNode CreateSimpleLayout() + private static Node CreateNode( + float? width = null, + float? height = null, + FlexDirection? direction = null, + float? grow = null, + Wrap? wrap = null, + float? padding = null, + float? gap = null) { - return new FlexNode() - .Direction(FlexDirection.Row) - .Size(800, 600) - .AddChildren( - new FlexNode().Size(200, 100), - new FlexNode().Size(200, 100), - new FlexNode().Size(200, 100) - ); + Node node = new(); + Style style = node.Style; + if (width is float w) + { + style.SetDimension(Dimension.Width, StyleSizeLength.Points(w)); + } + + if (height is float h) + { + style.SetDimension(Dimension.Height, StyleSizeLength.Points(h)); + } + + if (direction is FlexDirection d) + { + style.FlexDirection = d; + } + + if (grow is float g) + { + style.FlexGrow = g; + } + + if (wrap is Wrap wr) + { + style.FlexWrap = wr; + } + + if (padding is float p) + { + style.SetPadding(Edge.All, StyleLength.Points(p)); + } + + if (gap is float gp) + { + style.SetGap(Gutter.All, StyleLength.Points(gp)); + } + + return node; + } + + private static Node CreateSimpleLayout() + { + Node root = CreateNode(width: 800, height: 600, direction: FlexDirection.Row); + root.InsertChild(CreateNode(width: 200, height: 100), 0); + root.InsertChild(CreateNode(width: 200, height: 100), 1); + root.InsertChild(CreateNode(width: 200, height: 100), 2); + return root; } - private static FlexNode CreateDeepLayout(int depth) + private static Node CreateDeepLayout(int depth) { - FlexNode root = new FlexNode() - .Direction(FlexDirection.Column) - .Size(800, 600); + Node root = CreateNode(width: 800, height: 600, direction: FlexDirection.Column); - FlexNode current = root; + Node current = root; for (int i = 0; i < depth; i++) { - FlexNode child = new FlexNode() - .Direction(i % 2 == 0 ? FlexDirection.Row : FlexDirection.Column) - .Grow(1) - .Padding(5); + Node child = CreateNode( + direction: i % 2 == 0 ? FlexDirection.Row : FlexDirection.Column, + grow: 1, + padding: 5); - current.AddChild(child); + current.InsertChild(child, 0); current = child; } // Add leaf nodes at the deepest level - current.AddChildren( - new FlexNode().Size(50, 50), - new FlexNode().Size(50, 50) - ); + current.InsertChild(CreateNode(width: 50, height: 50), 0); + current.InsertChild(CreateNode(width: 50, height: 50), 1); return root; } - private static FlexNode CreateWideLayout(int childCount) + private static Node CreateWideLayout(int childCount) { - FlexNode root = new FlexNode() - .Direction(FlexDirection.Row) - .Wrap(FlexWrap.Wrap) - .Size(800, 600); + Node root = CreateNode(width: 800, height: 600, direction: FlexDirection.Row, wrap: Wrap.Wrap); for (int i = 0; i < childCount; i++) { - root.AddChild(new FlexNode().Size(75, 50)); + root.InsertChild(CreateNode(width: 75, height: 50), i); } return root; } - private static FlexNode CreateFlexGrowLayout() + private static Node CreateFlexGrowLayout() { - return new FlexNode() - .Direction(FlexDirection.Row) - .Size(800, 600) - .AddChildren( - new FlexNode().Width(100).Grow(0), - new FlexNode().Grow(1), - new FlexNode().Grow(2), - new FlexNode().Width(100).Grow(0), - new FlexNode().Grow(1) - ); + Node root = CreateNode(width: 800, height: 600, direction: FlexDirection.Row); + root.InsertChild(CreateNode(width: 100, grow: 0), 0); + root.InsertChild(CreateNode(grow: 1), 1); + root.InsertChild(CreateNode(grow: 2), 2); + root.InsertChild(CreateNode(width: 100, grow: 0), 3); + root.InsertChild(CreateNode(grow: 1), 4); + return root; } - private static FlexNode CreateWrappingLayout() + private static Node CreateWrappingLayout() { - FlexNode root = new FlexNode() - .Direction(FlexDirection.Row) - .Wrap(FlexWrap.Wrap) - .Size(800, 600) - .Gap(10); + Node root = CreateNode(width: 800, height: 600, direction: FlexDirection.Row, wrap: Wrap.Wrap, gap: 10); for (int i = 0; i < 50; i++) { - root.AddChild(new FlexNode().Size(150, 100)); + root.InsertChild(CreateNode(width: 150, height: 100), i); } return root; diff --git a/kanban/backlog/.gitkeep b/kanban/backlog/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/kanban/to-do/140-release-1.0.0-beta.3.md b/kanban/to-do/140-release-1.0.0-beta.3.md new file mode 100644 index 0000000..73cbf34 --- /dev/null +++ b/kanban/to-do/140-release-1.0.0-beta.3.md @@ -0,0 +1,34 @@ +# Task 140 - Release 1.0.0-beta.3 + +## Summary + +Publish the first release of the rewritten (Yoga-port) engine. The three release +blockers were cleared on 2026-07-03: MIT LICENSE added (with Yoga attribution note), +readme rewritten against the current `Node`/`Style`/`CalculateLayout` API (example +verified compiling and producing the documented output), and version bumped to +1.0.0-beta.3 with `PackageLicenseExpression` set to MIT. Local `dotnet pack` +verified: dll + readme + logo, MIT license, version 1.0.0-beta.3. + +## Todo List + +- [ ] Merge dev to master via PR (green CI) +- [ ] Create GitHub release `v1.0.0-beta.3` — the release event triggers the + workflow's publish step to GitHub Packages +- [ ] Release notes must state clearly: the API is entirely new; beta.2 and earlier + were a different implementation (`FlexNode`/`FlexLayoutEngine`) that no longer + exists. Known gaps: intrinsic text measurement untested, + FixFlexBasisFitContent experimental feature unimplemented. +- [ ] Verify the package appears on the GitHub Packages feed and installs into a + consumer project + +## Toward stable 1.0 (follow-ups, not blockers for beta.3) + +- [ ] Task 139: restore style/analyzer gates (naming decision + hand fixes) +- [ ] Port remaining hand-written Yoga unit tests (Baseline, AspectRatio, Edge, + HadOverflow, Dirtied, Persistence, FlexGap, ...) +- [ ] Text-measurement test helper to unlock skipped IntrinsicSize tests +- [ ] Enable GenerateDocumentationFile so the package ships XML docs + +## Results + +(Add after completion) diff --git a/readme.md b/readme.md index 7a0b4ff..59bae8e 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,12 @@ A pure C# implementation of the Flexbox layout algorithm for .NET applications. +TimeWarp.Flexbox is a from-scratch C# port of the algorithm behind Facebook's +[Yoga](https://github.com/facebook/yoga) layout engine. It computes CSS-flexbox +layouts (positions and sizes) for a tree of nodes — no UI framework required — +and its behavior is verified against Yoga's own generated conformance suite +(530 tests, LTR and RTL). + ## Installation This is a private package hosted on GitHub Packages. To consume it, you need to configure authentication. @@ -58,7 +64,7 @@ Note: `GITHUB_TOKEN` works for repositories within the same organization. For ex ### 4. Add Package Reference ```xml - + ``` ## Usage @@ -67,25 +73,59 @@ Note: `GITHUB_TOKEN` works for repositories within the same organization. For ex using TimeWarp.Flexbox; // Create a flex container -var root = new FlexNode -{ - Width = 300, - Height = 200, - FlexDirection = FlexDirection.Row -}; +Node root = new(); +root.Style.FlexDirection = FlexDirection.Row; +root.Style.SetDimension(Dimension.Width, StyleSizeLength.Points(300)); +root.Style.SetDimension(Dimension.Height, StyleSizeLength.Points(200)); + +// Add children that share the free space 1:2 +Node left = new(); +left.Style.FlexGrow = 1; +root.InsertChild(left, 0); -// Add children -root.AddChild(new FlexNode { FlexGrow = 1 }); -root.AddChild(new FlexNode { FlexGrow = 2 }); +Node right = new(); +right.Style.FlexGrow = 2; +root.InsertChild(right, 1); // Calculate layout -FlexLayoutEngine.CalculateLayout(root); +CalculateLayout.Calculate(root, float.NaN, float.NaN, Direction.LTR); + +// Read computed positions and sizes +Console.WriteLine($"left: x={left.Layout.GetPosition(PhysicalEdge.Left)} width={left.Layout.GetDimension(Dimension.Width)}"); +Console.WriteLine($"right: x={right.Layout.GetPosition(PhysicalEdge.Left)} width={right.Layout.GetDimension(Dimension.Width)}"); +// left: x=0 width=100 +// right: x=100 width=200 +``` + +Styles are set through `Node.Style`: + +- Dimensions: `SetDimension`, `SetMinDimension`, `SetMaxDimension` with + `StyleSizeLength.Points(..)`, `.Percent(..)`, `.Auto` +- Flex: `FlexDirection`, `FlexGrow`, `FlexShrink`, `FlexBasis`, `FlexWrap` +- Alignment: `JustifyContent`, `AlignItems`, `AlignSelf`, `AlignContent` +- Box model: `SetMargin`, `SetPadding`, `SetBorder` per `Edge`, `SetGap` per `Gutter` +- Positioning: `PositionType` (relative/absolute/static) with `SetPosition` insets + +Results are read from `Node.Layout` after calling `CalculateLayout.Calculate`: +`GetPosition(PhysicalEdge)` and `GetDimension(Dimension)`. + +### Defaults + +Defaults match Yoga (not web CSS): `flex-direction: column`, `flex-shrink: 0`, +`align-content: flex-start`, and `box-sizing: border-box`. Construct nodes with +`new Node(new Config { UseWebDefaults = true })` for web-style defaults. + +### Demo + +Run the visual demo to see computed layouts rendered as ASCII boxes, or as an +HTML page comparing the engine's output against your browser's native flexbox: -// Access computed layout -Console.WriteLine($"Child 0: {root[0].LayoutResult}"); -Console.WriteLine($"Child 1: {root[1].LayoutResult}"); +```bash +./samples/layout-demo/layout-demo.cs # terminal +./samples/layout-demo/layout-demo.cs --html out.html # browser comparison ``` ## License -Unlicense - See [LICENSE](LICENSE) for details. +MIT — see [LICENSE](LICENSE) for details. Behavior modeled on Facebook's +[Yoga](https://github.com/facebook/yoga) (MIT, Copyright (c) Meta Platforms, Inc.). diff --git a/runfiles/build.cs b/runfiles/build.cs index 905f477..5451b6a 100755 --- a/runfiles/build.cs +++ b/runfiles/build.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:package TimeWarp.Amuru #:property EnablePreviewFeatures=true #:property NoWarn=CA1303;CA2007 diff --git a/runfiles/port-generated-tests.cs b/runfiles/port-generated-tests.cs index 8398ef9..94e4cbd 100755 --- a/runfiles/port-generated-tests.cs +++ b/runfiles/port-generated-tests.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:property EnablePreviewFeatures=true #:property NoWarn=CA1303;CA2007 diff --git a/runfiles/test.cs b/runfiles/test.cs index aad9213..11592dd 100755 --- a/runfiles/test.cs +++ b/runfiles/test.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:package TimeWarp.Amuru #:property EnablePreviewFeatures=true #:property NoWarn=CA1303;CA2007 diff --git a/samples/layout-demo/layout-demo.cs b/samples/layout-demo/layout-demo.cs index 3828e21..d629e78 100755 --- a/samples/layout-demo/layout-demo.cs +++ b/samples/layout-demo/layout-demo.cs @@ -1,5 +1,5 @@ -#!/usr/bin/dotnet -- -#:project ../../source/timewarp-flexbox/timewarp-flexbox.csproj +#!/usr/bin/env -S dotnet -- +#:project $(SourceDirectory)timewarp-flexbox/timewarp-flexbox.csproj #:property EnablePreviewFeatures=true #:property NoWarn=CA1303;CA2007;IDE0058 diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/source/Directory.Build.props b/source/Directory.Build.props index debfb6f..dcb42e0 100644 --- a/source/Directory.Build.props +++ b/source/Directory.Build.props @@ -4,10 +4,10 @@ - 1.0.0-beta.2 + 1.0.0-beta.3 Steven T. Cramer https://github.com/TimeWarpEngineering/timewarp-flexbox - Unlicense + MIT logo.png readme.md diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/timewarp-flexbox.slnx b/timewarp-flexbox.slnx index 5b44173..b973034 100644 --- a/timewarp-flexbox.slnx +++ b/timewarp-flexbox.slnx @@ -5,4 +5,7 @@ + + + diff --git a/tools/dev-cli/Directory.Build.props b/tools/dev-cli/Directory.Build.props new file mode 100644 index 0000000..2e2e75f --- /dev/null +++ b/tools/dev-cli/Directory.Build.props @@ -0,0 +1,28 @@ + + + + + + $(NoWarn);CA1031;CA1303;CA1508;CA1515;CA1849;CA2007;CA2016;CA2000;CA1034;CA5399;IDE0065;IL2026;IL2104;IL3050;IL3053;RCS1046 + + + true + true + + + $(InterceptorsNamespaces);TimeWarp.Nuru.Generated + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/dev-cli/dev.cs b/tools/dev-cli/dev.cs new file mode 100755 index 0000000..48e3d20 --- /dev/null +++ b/tools/dev-cli/dev.cs @@ -0,0 +1,40 @@ +#!/usr/bin/env -S dotnet -- +// ═══════════════════════════════════════════════════════════════════════════════ +// DEV CLI - timewarp-flexbox DEVELOPMENT TOOL +// ═══════════════════════════════════════════════════════════════════════════════ +// +// Usage: +// As runfile: dotnet run tools/dev-cli/dev.cs -- +// As AOT: ./bin/dev +// +// Run `./bin/dev --help` for available commands. +// +// To bootstrap: +// dotnet run tools/dev-cli/dev.cs -- self-install +// direnv allow +// dev --help +// ═══════════════════════════════════════════════════════════════════════════════ + +#region Purpose +// Entry point for the dev CLI +#endregion +#region Design +// Thin wrapper around TimeWarp.Nuru to execute development commands +#endregion + +NuruApp app = NuruApp.CreateBuilder() + .WithName("dev") + .WithDescription("Development CLI for timewarp-flexbox") + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }) + .DiscoverEndpoints() + .Build(); + +return await app.RunAsync(args); \ No newline at end of file diff --git a/tools/dev-cli/endpoints/build-command.cs b/tools/dev-cli/endpoints/build-command.cs new file mode 100644 index 0000000..5e3a4cf --- /dev/null +++ b/tools/dev-cli/endpoints/build-command.cs @@ -0,0 +1,112 @@ +#region Purpose +// Build all projects in the repository +#endregion +#region Design +// Discovers the repository root dynamically using Git.FindRoot() +// Handler stores Command and Ct as fields so private methods are zero-parameter +// Streams MSBuild output via Amuru RunAsync by default; --quiet uses CaptureAsync +#endregion + +namespace DevCli.Commands; + +[NuruRoute("build", Description = "Build all projects in the repository")] +internal sealed class BuildCommand : ICommand +{ + [Option("clean", "c", Description = "Clean before building")] + public bool Clean { get; set; } + + [Option("quiet", "q", Description = "Hide build output unless the command fails")] + public bool Quiet { get; set; } + + internal sealed class Handler : ICommandHandler + { + private readonly ITerminal Terminal; + private BuildCommand Command = null!; + private CancellationToken Ct; + private string RepoRoot = null!; + + public Handler(ITerminal terminal) + { + Terminal = terminal; + } + + public async ValueTask Handle(BuildCommand command, CancellationToken ct) + { + Command = command; + Ct = ct; + + if (!FindRepoRoot()) return Value; + if (!await CleanAsync()) return Value; + if (!await BuildAsync()) return Value; + + Terminal.WriteLine("\nBuild completed successfully!".Green()); + return Value; + } + + private bool FindRepoRoot() + { + string? root = Git.FindRoot(); + if (root is null) + { + Terminal.WriteErrorLine("Error: could not find repository root."); + Environment.ExitCode = 1; + return false; + } + RepoRoot = root; + Terminal.WriteLine($"Building repository at {RepoRoot}..."); + return true; + } + + private async Task CleanAsync() + { + if (!Command.Clean) return true; + + Terminal.WriteLine("\nCleaning before build..."); + CommandResult command = DotNet.Clean() + .WithWorkingDirectory(RepoRoot) + .WithNoValidation() + .Build(); + + return await ExecuteAsync(command, "Clean failed!"); + } + + private async Task BuildAsync() + { + Terminal.WriteLine("\nBuilding..."); + CommandResult command = DotNet.Build() + .WithConfiguration("Release") + .WithWorkingDirectory(RepoRoot) + .WithNoValidation() + .Build(); + + return await ExecuteAsync(command, "Build failed!"); + } + + private async Task ExecuteAsync(CommandResult command, string failureMessage) + { + if (Command.Quiet) + { + CommandOutput result = await command.CaptureAsync(Ct); + if (!result.Success) + { + Terminal.WriteErrorLine(result.Combined); + Terminal.WriteErrorLine(failureMessage.Red()); + Environment.ExitCode = 1; + return false; + } + + return true; + } + + int exitCode = await command.RunAsync(Ct); + if (exitCode != 0) + { + Terminal.WriteErrorLine(failureMessage.Red()); + Environment.ExitCode = exitCode; + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/tools/dev-cli/endpoints/test-command.cs b/tools/dev-cli/endpoints/test-command.cs new file mode 100644 index 0000000..53cc87a --- /dev/null +++ b/tools/dev-cli/endpoints/test-command.cs @@ -0,0 +1,89 @@ +#region Purpose +// Run the test suite +#endregion +#region Design +// Executes dotnet test for all tests in the repository +// Handler stores Command and Ct as fields so private methods are zero-parameter +// Streams test output via Amuru RunAsync by default; --quiet uses CaptureAsync +#endregion + +namespace DevCli.Commands; + +[NuruRoute("test", Description = "Run the test suite")] +internal sealed class TestCommand : ICommand +{ + [Option("quiet", "q", Description = "Hide test output unless the command fails")] + public bool Quiet { get; set; } + + internal sealed class Handler : ICommandHandler + { + private readonly ITerminal Terminal; + private TestCommand Command = null!; + private CancellationToken Ct; + private string RepoRoot = null!; + + public Handler(ITerminal terminal) + { + Terminal = terminal; + } + + public async ValueTask Handle(TestCommand command, CancellationToken ct) + { + Command = command; + Ct = ct; + + if (!FindRepoRoot()) return Value; + if (!await TestAsync()) return Value; + + Terminal.WriteLine("\nTests completed successfully!".Green()); + return Value; + } + + private bool FindRepoRoot() + { + string? root = Git.FindRoot(); + if (root is null) + { + Terminal.WriteErrorLine("Error: could not find repository root."); + Environment.ExitCode = 1; + return false; + } + RepoRoot = root; + Terminal.WriteLine("Running test suite..."); + return true; + } + + private async Task TestAsync() + { + CommandResult command = DotNet.Test() + .WithConfiguration("Release") + .WithWorkingDirectory(RepoRoot) + .WithNoValidation() + .Build(); + + if (Command.Quiet) + { + CommandOutput result = await command.CaptureAsync(Ct); + if (!result.Success) + { + Terminal.WriteErrorLine(result.Combined); + Terminal.WriteErrorLine("Tests failed!".Red()); + Environment.ExitCode = 1; + return false; + } + + return true; + } + + int exitCode = await command.RunAsync(Ct); + if (exitCode != 0) + { + Terminal.WriteErrorLine("Tests failed!".Red()); + Environment.ExitCode = exitCode; + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/tools/dev-cli/endpoints/verify-samples-command.cs b/tools/dev-cli/endpoints/verify-samples-command.cs new file mode 100644 index 0000000..c9c03f9 --- /dev/null +++ b/tools/dev-cli/endpoints/verify-samples-command.cs @@ -0,0 +1,30 @@ +#region Purpose +// Verifies that any code samples in the repository compile +#endregion +#region Design +// Stub command for sample verification +#endregion + +namespace DevCli.Commands; + +[NuruRoute("verify-samples", Description = "Verify code samples compile")] +internal sealed class VerifySamplesCommand : ICommand +{ + internal sealed class Handler : ICommandHandler + { + private readonly ITerminal Terminal; + + public Handler(ITerminal terminal) + { + Terminal = terminal; + } + + public ValueTask Handle(VerifySamplesCommand command, CancellationToken ct) + { + Terminal.WriteLine("Verifying samples..."); + // TODO: Implement sample verification logic specific to this repo + Terminal.WriteLine("Samples verified successfully!"); + return ValueTask.FromResult(Value); + } + } +} diff --git a/tools/dev-cli/endpoints/workflow-command.cs b/tools/dev-cli/endpoints/workflow-command.cs new file mode 100644 index 0000000..7fa980c --- /dev/null +++ b/tools/dev-cli/endpoints/workflow-command.cs @@ -0,0 +1,71 @@ +#region Purpose +// Executes the full CI workflow +#endregion +#region Design +// Runs clean, build, test sequentially by invoking ./bin/dev subcommands +// Handler stores RepoRoot and DevBin as fields so private methods are zero-parameter +// RunStepAsync DRYs up the identical shell-invoke/exit-code-check pattern +#endregion + +namespace DevCli.Commands; + +[NuruRoute("workflow", Description = "Execute full CI workflow")] +internal sealed class WorkflowCommand : ICommand +{ + internal sealed class Handler : ICommandHandler + { + private readonly ITerminal Terminal; + private CancellationToken Ct; + private string RepoRoot = null!; + private string DevBin = null!; + + public Handler(ITerminal terminal) + { + Terminal = terminal; + } + + public async ValueTask Handle(WorkflowCommand command, CancellationToken ct) + { + Ct = ct; + + if (!FindRepoRoot()) return Value; + if (!await RunStepAsync("clean", "Clean failed!")) return Value; + if (!await RunStepAsync("build", "Build failed!")) return Value; + if (!await RunStepAsync("test", "Tests failed!")) return Value; + + Terminal.WriteLine("\nWorkflow completed successfully!".Green()); + return Value; + } + + private bool FindRepoRoot() + { + string? root = Git.FindRoot(); + if (root is null) + { + Terminal.WriteErrorLine("Error: could not find repository root."); + Environment.ExitCode = 1; + return false; + } + RepoRoot = root; + DevBin = Path.Combine(RepoRoot, "bin", "dev"); + Terminal.WriteLine("Starting CI workflow..."); + return true; + } + + private async Task RunStepAsync(string subcommand, string failureMessage) + { + int exitCode = await Shell.Builder(DevBin) + .WithArguments(subcommand) + .WithNoValidation() + .RunAsync(Ct); + + if (exitCode != 0) + { + Terminal.WriteErrorLine(failureMessage.Red()); + Environment.ExitCode = exitCode; + return false; + } + return true; + } + } +} diff --git a/tools/dev-cli/global-usings.cs b/tools/dev-cli/global-usings.cs new file mode 100644 index 0000000..fff0e42 --- /dev/null +++ b/tools/dev-cli/global-usings.cs @@ -0,0 +1,21 @@ +#region Purpose +// Global usings for dev-cli +#endregion + +global using System; +global using System.IO; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; +global using System.Collections.Generic; +global using System.Diagnostics; +global using System.Runtime.CompilerServices; +global using System.Runtime.InteropServices; +global using System.Xml.Linq; + +global using TimeWarp.Nuru; +global using static TimeWarp.Nuru.Unit; +global using TimeWarp.Amuru; +global using TimeWarp.Terminal; +global using DevCli; +global using Microsoft.Extensions.DependencyInjection; \ No newline at end of file diff --git a/tools/dev-cli/source/Directory.Build.props b/tools/dev-cli/source/Directory.Build.props new file mode 100644 index 0000000..fc9e93e --- /dev/null +++ b/tools/dev-cli/source/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + + + 1.0.0-beta.1 + TimeWarpEngineering + https://github.com/TimeWarpEngineering/timewarp-flexbox + https://github.com/TimeWarpEngineering/timewarp-flexbox + git + Unlicense + logo.png + + + + + + +